Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 7b7bd0c3e8 | |||
| 26c368c35e | |||
| 36786defd1 | |||
| 8019537b02 | |||
| 3f240bc777 | |||
| e41d603c12 | |||
| 1cc9553d1f | |||
| a62f07bd3a | |||
| c2f9bbaff4 | |||
| abc225e12a | |||
| 3c646c6974 | |||
| aff346c731 | |||
| 13b3fca949 | |||
| 6e0d0e5723 | |||
| 752906b054 | |||
| fdd14ef04c | |||
| 9a57dc4bec | |||
| 4ecb1680bf | |||
| d2a22e49e3 | |||
| f167ddfbfb | |||
| 653ed95f14 | |||
| b2c6f6f0e9 | |||
| 962b4dbc8c | |||
| aa4089118e | |||
| dabca3ad24 | |||
| 7c791dc8e4 | |||
| 8f29cc7414 | |||
| c8b21aa76e | |||
| 483804fc9e | |||
| 12a8cfccb5 | |||
| e358a92cf4 | |||
| 63b25e65ad | |||
| a766b7193f | |||
| e95138d412 | |||
| 7426f6c718 | |||
| e994aae613 | |||
| c39a9ca18f | |||
| 06e155abb6 | |||
| 6b11208e5a | |||
| 41da012494 | |||
| 8a3104443c | |||
| 5f5ab69da6 | |||
| 5386ae870f | |||
| d7b011e52f | |||
| 11466e1525 | |||
| e185b36138 | |||
| 31d8c30dfe | |||
| 69ccd7b25d | |||
| 8b77c6be97 | |||
| c33ef18a50 | |||
| 4c9450c117 | |||
| b467cb7532 | |||
| c78bbb7ba5 |
@@ -2,8 +2,8 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to main → :main and :latest (no APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (APK bundled)
|
||||
# push to main → :main and :latest (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
#
|
||||
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
||||
@@ -20,6 +20,14 @@ name: release
|
||||
# happens in the same android-release job, so the Release-page download
|
||||
# link and the in-image bundled APK are both populated atomically.
|
||||
#
|
||||
# :latest always carries an APK. Because every main push also moves
|
||||
# :latest (not just tags), a main build with no APK would silently strip
|
||||
# the in-app update channel off :latest until the next release. So on
|
||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||
# and reconstructs its exact versionName (tag + commit-count, the same
|
||||
# formula android-release bakes in) for the version sidecar — no rebuild,
|
||||
# just rebundle. Tag builds keep bundling their own freshly-built APK.
|
||||
#
|
||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||
# lives in android.yml and runs independently on every push.
|
||||
|
||||
@@ -181,6 +189,12 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags so non-tag :latest builds can resolve the
|
||||
# latest release tag's commit count and reconstruct the bundled
|
||||
# APK's exact versionName (see "Bundle latest release APK" below).
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
@@ -221,9 +235,8 @@ jobs:
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Download signed APK artifact
|
||||
# Tag pushes only — android-release just produced this. Main
|
||||
# pushes skip and the image ships with empty client/ (the
|
||||
# /api/client/version endpoint then returns 404 by design).
|
||||
# Tag pushes only — android-release just produced this. Non-tag
|
||||
# builds take the "Bundle latest release APK" path below instead.
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
@@ -247,6 +260,42 @@ jobs:
|
||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Bundle latest release APK (non-tag :latest builds)
|
||||
# Main pushes don't build an APK, but they DO move :latest — so
|
||||
# without this the in-app update channel would vanish from :latest
|
||||
# until the next tag. Pull the most-recent release's signed APK and
|
||||
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
|
||||
# TAG) — identical to android-release's formula) so the version
|
||||
# sidecar the server hands clients matches the installed build.
|
||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||
# version — if no release / APK asset / tag-count can be resolved.
|
||||
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
||||
if [ -z "${REL_JSON}" ]; then
|
||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
|
||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
|
||||
if [ -z "${COUNT}" ]; then
|
||||
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
||||
fi
|
||||
VERSION_NAME="${TAG#v}.${COUNT}"
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||
echo "${VERSION_NAME}" > client/minstrel.apk.version
|
||||
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
|
||||
ls -lh client/
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
run: |
|
||||
|
||||
@@ -4,7 +4,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
|
||||
> State and intelligence belong on the server, not the client.
|
||||
|
||||
<!-- TODO: screenshot of the home page -->
|
||||
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -13,7 +13,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356).
|
||||
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -24,10 +24,18 @@ services:
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
# Your music library. Point ./music at wherever your audio files
|
||||
# live. Mounted read-only — Minstrel never writes to your library.
|
||||
- ./music:/music:ro
|
||||
- minstrel-data:/data
|
||||
# Generated data: playlist cover collages, artist art, caches.
|
||||
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||
# sets to /app/data — keep this mount on /app/data or your cache
|
||||
# won't survive a container recreate.
|
||||
- minstrel-data:/app/data
|
||||
environment:
|
||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||
# Colon-separated library roots to scan; must match the container
|
||||
# path of the read-only music mount above (/music here).
|
||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||
depends_on: [db]
|
||||
|
||||
@@ -37,6 +45,8 @@ services:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: minstrel
|
||||
POSTGRES_DB: minstrel
|
||||
# Postgres data dir — users, likes, play history, sessions, settings.
|
||||
# The one volume you must never lose; back it up with pg_dump.
|
||||
volumes: [pgdata:/var/lib/postgresql/data]
|
||||
|
||||
volumes:
|
||||
@@ -48,7 +58,29 @@ volumes:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
|
||||
## First run
|
||||
|
||||
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
|
||||
|
||||
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
|
||||
|
||||
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
||||
|
||||
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
|
||||
|
||||
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
||||
|
||||
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
|
||||
|
||||
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
|
||||
|
||||
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
|
||||
|
||||
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The "Install the Android app" download card in Settings"></a>
|
||||
|
||||
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
|
||||
|
||||
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
|
||||
|
||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||
|
||||
@@ -57,7 +89,7 @@ For the full configuration surface, see [`config.example.yaml`](./config.example
|
||||
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||
|
||||
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
||||
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
|
||||
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||
|
||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||
@@ -66,8 +98,13 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
## Updating
|
||||
|
||||
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.
|
||||
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
|
||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||
|
||||
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
|
||||
## Specs
|
||||
|
||||
|
||||
Generated
+13
@@ -1754,6 +1754,19 @@
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="36" />
|
||||
<option name="brand" value="google" />
|
||||
<option name="codename" value="tangorpro" />
|
||||
<option name="formFactor" value="Tablet" />
|
||||
<option name="id" value="tangorpro" />
|
||||
<option name="labId" value="google" />
|
||||
<option name="manufacturer" value="Google" />
|
||||
<option name="name" value="Pixel Tablet" />
|
||||
<option name="screenDensity" value="320" />
|
||||
<option name="screenX" value="1600" />
|
||||
<option name="screenY" value="2560" />
|
||||
</PersistentDeviceSelectionData>
|
||||
<PersistentDeviceSelectionData>
|
||||
<option name="api" value="35" />
|
||||
<option name="brand" value="google" />
|
||||
|
||||
Generated
-1
@@ -1,4 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 6,
|
||||
"identityHash": "fb73ed8674efb1d82a586551baba5ef0",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "sync_metadata",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `cursor` INTEGER NOT NULL, `lastSyncAt` INTEGER, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cursor",
|
||||
"columnName": "cursor",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastSyncAt",
|
||||
"columnName": "lastSyncAt",
|
||||
"affinity": "INTEGER"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_artists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortName` TEXT NOT NULL, `mbid` TEXT, `artistThumbPath` TEXT, `artistFanartPath` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortName",
|
||||
"columnName": "sortName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "mbid",
|
||||
"columnName": "mbid",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistThumbPath",
|
||||
"columnName": "artistThumbPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistFanartPath",
|
||||
"columnName": "artistFanartPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_albums",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `sortTitle` TEXT NOT NULL, `releaseDate` TEXT, `coverPath` TEXT, `mbid` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sortTitle",
|
||||
"columnName": "sortTitle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "releaseDate",
|
||||
"columnName": "releaseDate",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverPath",
|
||||
"columnName": "coverPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "mbid",
|
||||
"columnName": "mbid",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_tracks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `title` TEXT NOT NULL, `durationMs` INTEGER NOT NULL, `trackNumber` INTEGER, `discNumber` INTEGER, `filePath` TEXT, `fileFormat` TEXT, `genre` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationMs",
|
||||
"columnName": "durationMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackNumber",
|
||||
"columnName": "trackNumber",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "discNumber",
|
||||
"columnName": "discNumber",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "filePath",
|
||||
"columnName": "filePath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fileFormat",
|
||||
"columnName": "fileFormat",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "genre",
|
||||
"columnName": "genre",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_likes",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`userId` TEXT NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `likedAt` INTEGER NOT NULL, PRIMARY KEY(`userId`, `entityType`, `entityId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityType",
|
||||
"columnName": "entityType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityId",
|
||||
"columnName": "entityId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "likedAt",
|
||||
"columnName": "likedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"userId",
|
||||
"entityType",
|
||||
"entityId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `userId` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `isPublic` INTEGER NOT NULL, `coverPath` TEXT, `trackCount` INTEGER NOT NULL, `durationSec` INTEGER NOT NULL, `systemVariant` TEXT, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userId",
|
||||
"columnName": "userId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "description",
|
||||
"columnName": "description",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isPublic",
|
||||
"columnName": "isPublic",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "coverPath",
|
||||
"columnName": "coverPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackCount",
|
||||
"columnName": "trackCount",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "durationSec",
|
||||
"columnName": "durationSec",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "systemVariant",
|
||||
"columnName": "systemVariant",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_playlist_tracks",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `trackId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlistId`, `trackId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "playlistId",
|
||||
"columnName": "playlistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackId",
|
||||
"columnName": "trackId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"playlistId",
|
||||
"trackId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_quarantine_mine",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `reason` TEXT NOT NULL, `notes` TEXT, `createdAt` TEXT NOT NULL, `trackTitle` TEXT NOT NULL, `trackDurationMs` INTEGER NOT NULL, `albumId` TEXT NOT NULL, `albumTitle` TEXT NOT NULL, `albumCoverArtPath` TEXT, `artistId` TEXT NOT NULL, `artistName` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`trackId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "trackId",
|
||||
"columnName": "trackId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "reason",
|
||||
"columnName": "reason",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "notes",
|
||||
"columnName": "notes",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackTitle",
|
||||
"columnName": "trackTitle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trackDurationMs",
|
||||
"columnName": "trackDurationMs",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumId",
|
||||
"columnName": "albumId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumTitle",
|
||||
"columnName": "albumTitle",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "albumCoverArtPath",
|
||||
"columnName": "albumCoverArtPath",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistId",
|
||||
"columnName": "artistId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "artistName",
|
||||
"columnName": "artistName",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"trackId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "audio_cache_index",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`trackId` TEXT NOT NULL, `path` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `cachedAt` INTEGER NOT NULL, `lastPlayedAt` INTEGER, `source` TEXT NOT NULL, PRIMARY KEY(`trackId`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "trackId",
|
||||
"columnName": "trackId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "path",
|
||||
"columnName": "path",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sizeBytes",
|
||||
"columnName": "sizeBytes",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cachedAt",
|
||||
"columnName": "cachedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastPlayedAt",
|
||||
"columnName": "lastPlayedAt",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "source",
|
||||
"columnName": "source",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"trackId"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_mutations",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `kind` TEXT NOT NULL, `payload` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAttemptAt` INTEGER, `attempts` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "kind",
|
||||
"columnName": "kind",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "payload",
|
||||
"columnName": "payload",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "createdAt",
|
||||
"columnName": "createdAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "lastAttemptAt",
|
||||
"columnName": "lastAttemptAt",
|
||||
"affinity": "INTEGER"
|
||||
},
|
||||
{
|
||||
"fieldPath": "attempts",
|
||||
"columnName": "attempts",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_resume_state",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "json",
|
||||
"columnName": "json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_home_index",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`section` TEXT NOT NULL, `position` INTEGER NOT NULL, `entityType` TEXT NOT NULL, `entityId` TEXT NOT NULL, `fetchedAt` INTEGER NOT NULL, PRIMARY KEY(`section`, `position`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "section",
|
||||
"columnName": "section",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "position",
|
||||
"columnName": "position",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityType",
|
||||
"columnName": "entityType",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "entityId",
|
||||
"columnName": "entityId",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "fetchedAt",
|
||||
"columnName": "fetchedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"section",
|
||||
"position"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "cached_history_snapshot",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `json` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "json",
|
||||
"columnName": "json",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "updatedAt",
|
||||
"columnName": "updatedAt",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"tableName": "auth_session",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `sessionCookie` TEXT, `baseUrl` TEXT NOT NULL, `userJson` TEXT, `themeMode` TEXT, `clientId` TEXT, `cacheSettingsJson` TEXT, PRIMARY KEY(`id`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "sessionCookie",
|
||||
"columnName": "sessionCookie",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "baseUrl",
|
||||
"columnName": "baseUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "userJson",
|
||||
"columnName": "userJson",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "themeMode",
|
||||
"columnName": "themeMode",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "clientId",
|
||||
"columnName": "clientId",
|
||||
"affinity": "TEXT"
|
||||
},
|
||||
{
|
||||
"fieldPath": "cacheSettingsJson",
|
||||
"columnName": "cacheSettingsJson",
|
||||
"affinity": "TEXT"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fb73ed8674efb1d82a586551baba5ef0')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import com.fabledsword.minstrel.auth.ui.AuthGateViewModel
|
||||
import com.fabledsword.minstrel.cache.CachedTrackIds
|
||||
import com.fabledsword.minstrel.connectivity.LocalServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.LocalDetailSeedCache
|
||||
import com.fabledsword.minstrel.nav.MinstrelNavGraph
|
||||
@@ -41,7 +41,7 @@ import javax.inject.Inject
|
||||
class MainActivity : ComponentActivity() {
|
||||
@Inject lateinit var seedCache: DetailSeedCache
|
||||
@Inject lateinit var cachedTrackIds: CachedTrackIds
|
||||
@Inject lateinit var serverHealth: ServerHealthController
|
||||
@Inject lateinit var serverHealth: NetworkStatusController
|
||||
|
||||
// Flipped to true when the user taps the media notification (or
|
||||
// any other entry point that asks for the full player). The App
|
||||
@@ -91,7 +91,7 @@ class MainActivity : ComponentActivity() {
|
||||
private fun App(
|
||||
seedCache: DetailSeedCache,
|
||||
cachedTrackIds: CachedTrackIds,
|
||||
serverHealth: ServerHealthController,
|
||||
serverHealth: NetworkStatusController,
|
||||
pendingOpenNowPlaying: StateFlow<Boolean>,
|
||||
onOpenedNowPlaying: () -> Unit,
|
||||
themeVm: ThemePreferenceViewModel = hiltViewModel(),
|
||||
|
||||
@@ -19,8 +19,7 @@ import com.fabledsword.minstrel.player.PlayEventsReporter
|
||||
import com.fabledsword.minstrel.player.PlaybackErrorReporter
|
||||
import com.fabledsword.minstrel.player.ResumeController
|
||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -115,21 +114,13 @@ class MinstrelApplication :
|
||||
@Suppress("unused") @Inject lateinit var audioPrefetcher: AudioPrefetcher
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — VersionCheckController's
|
||||
* init block starts a 5-min poll loop against /healthz so the
|
||||
* shell-level VersionTooOldBanner can surface min_client_version
|
||||
* mismatches without waiting for the next user-driven request.
|
||||
* Same construct-the-singleton trick — NetworkStatusController owns the
|
||||
* /healthz poll loop + the device-link collector + the reachability state
|
||||
* machine, and is the single authority on the tri-state ServerHealth
|
||||
* signal (plus the VersionTooOld byproduct). It must exist from launch so
|
||||
* the poll loop runs and the StateFlow stays warm for every consumer.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var versionCheckController: VersionCheckController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — ServerHealthController combines
|
||||
* ConnectivityObserver + VersionCheckController.reachable into the
|
||||
* tri-state ServerHealth signal. Its stateIn is `SharingStarted.Eagerly`
|
||||
* so the StateFlow needs an active subscriber from launch onward; the
|
||||
* @Inject keeps the singleton alive and the flow collecting.
|
||||
*/
|
||||
@Suppress("unused") @Inject lateinit var serverHealthController: ServerHealthController
|
||||
@Suppress("unused") @Inject lateinit var networkStatusController: NetworkStatusController
|
||||
|
||||
/**
|
||||
* Same construct-the-singleton trick — UpdateBannerController polls
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.fabledsword.minstrel.api
|
||||
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.connectivity.ReachabilityReportingInterceptor
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -45,9 +46,15 @@ object NetworkModule {
|
||||
fun provideOkHttp(
|
||||
baseUrl: BaseUrlInterceptor,
|
||||
auth: AuthCookieInterceptor,
|
||||
reachability: ReachabilityReportingInterceptor,
|
||||
logging: HttpLoggingInterceptor,
|
||||
): OkHttpClient =
|
||||
OkHttpClient.Builder()
|
||||
// ReachabilityReportingInterceptor MUST run first: it identifies
|
||||
// Minstrel-bound requests by the still-unrewritten PLACEHOLDER_HOST
|
||||
// (so external artwork fetches don't read as server reachability)
|
||||
// and observes the final transport outcome by wrapping the chain.
|
||||
.addInterceptor(reachability)
|
||||
// AuthCookieInterceptor MUST run before BaseUrlInterceptor.
|
||||
// Both scope on `host == PLACEHOLDER_HOST` to distinguish
|
||||
// Minstrel-server requests from external image fetches
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
@@ -35,9 +36,26 @@ interface LibraryApi {
|
||||
@GET("api/artists/{id}/tracks")
|
||||
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
||||
|
||||
@GET("api/artists/{id}/similar")
|
||||
suspend fun getSimilarArtists(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
|
||||
): List<ArtistWire>
|
||||
|
||||
@GET("api/artists/{id}/top-tracks")
|
||||
suspend fun getArtistTopTracks(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
|
||||
): List<TrackWire>
|
||||
|
||||
@GET("api/albums/{id}")
|
||||
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
||||
|
||||
@GET("api/library/shuffle")
|
||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||
|
||||
private companion object {
|
||||
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||
const val TOP_TRACKS_LIMIT = 5
|
||||
}
|
||||
}
|
||||
|
||||
+14
@@ -31,6 +31,20 @@ interface CachedPlaylistDao {
|
||||
@Query("SELECT * FROM cached_playlists WHERE id = :id")
|
||||
suspend fun getById(id: String): CachedPlaylistEntity?
|
||||
|
||||
/**
|
||||
* Per-playlist count of member tracks resident in the audio cache index.
|
||||
* LEFT JOINs so playlists with zero cached tracks still appear
|
||||
* (cachedCount = 0). Drives the offline "fully cached" greying.
|
||||
*/
|
||||
@Query(
|
||||
"SELECT p.id AS playlistId, COUNT(a.trackId) AS cachedCount " +
|
||||
"FROM cached_playlists p " +
|
||||
"LEFT JOIN cached_playlist_tracks t ON t.playlistId = p.id " +
|
||||
"LEFT JOIN audio_cache_index a ON a.trackId = t.trackId " +
|
||||
"GROUP BY p.id",
|
||||
)
|
||||
fun observeCachedCounts(): Flow<List<PlaylistCachedCount>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(rows: List<CachedPlaylistEntity>)
|
||||
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.fabledsword.minstrel.cache.db.dao
|
||||
|
||||
/**
|
||||
* Projection: how many of a playlist's member tracks are resident in the audio
|
||||
* cache index. Backs the offline "fully cached" greying — a playlist is fully
|
||||
* available offline when [cachedCount] reaches its `trackCount`.
|
||||
*/
|
||||
data class PlaylistCachedCount(
|
||||
val playlistId: String,
|
||||
val cachedCount: Int,
|
||||
)
|
||||
Vendored
+5
@@ -14,6 +14,11 @@ import kotlinx.datetime.Instant
|
||||
* - "rediscover_artists"
|
||||
* - "most_played_tracks"
|
||||
* - "last_played_artists"
|
||||
* - "you_might_like_albums"
|
||||
* - "you_might_like_artists"
|
||||
*
|
||||
* New `section` values need no schema change — the column stores the
|
||||
* string verbatim and the DAO keys on it.
|
||||
*
|
||||
* `entityType` is "album" | "artist" | "track" — dispatches hydration
|
||||
* to the right per-entity endpoint when a tile is rendered.
|
||||
|
||||
+31
@@ -28,6 +28,13 @@ object MutationKind {
|
||||
const val PLAY_OFFLINE: String = "play_offline"
|
||||
const val REQUEST_CANCEL: String = "request_cancel"
|
||||
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
|
||||
|
||||
// Durable close-by-id for a play whose server row is already open
|
||||
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
|
||||
// a *whole* play when no server row exists. Using close-by-id on
|
||||
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||
// path produced (see 2026-06-11 contract audit).
|
||||
const val PLAY_ENDED: String = "play_ended"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +174,17 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Durable close-by-id for an already-open server play row. Background
|
||||
* path only (no user hint) — same as [enqueuePlayOffline].
|
||||
*/
|
||||
suspend fun enqueuePlayEnded(payload: PlayEndedPayload): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.PLAY_ENDED,
|
||||
payload = json.encodeToString(PlayEndedPayload.serializer(), payload),
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun insertUserDriven(kind: String, payload: String): Long {
|
||||
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
||||
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
||||
@@ -223,6 +241,19 @@ data class PlayOfflinePayload(
|
||||
val source: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.PLAY_ENDED`. The replayer re-fires
|
||||
* `POST /api/events` with type=play_ended, closing the already-open server
|
||||
* row [playEventId] at [durationPlayedMs]. Server-side close-by-id is
|
||||
* idempotent (re-ending updates the same row and skips a duplicate
|
||||
* skip_events insert), so a lost-response retry can't duplicate history.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayEndedPayload(
|
||||
val playEventId: String,
|
||||
val durationPlayedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.REQUEST_CANCEL` — the
|
||||
* `POST /api/requests/{id}/cancel` call lost during a connectivity
|
||||
|
||||
+170
-157
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("TooManyFunctions") // one dispatcher per mutation kind + replay helpers
|
||||
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
||||
@@ -10,6 +12,9 @@ import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
@@ -19,12 +24,16 @@ import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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.coroutines.sync.withLock
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -34,25 +43,24 @@ import javax.inject.Singleton
|
||||
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
|
||||
* re-fires each call against the raw Retrofit API (NOT the high-level
|
||||
* Repository, which would re-enqueue on failure → infinite loop), and
|
||||
* deletes the row on 2xx success.
|
||||
* resolves each to an [Outcome]:
|
||||
* - SENT — server accepted (2xx) → delete the row.
|
||||
* - DROP — permanently un-sendable (4xx, unknown kind/variant, corrupt
|
||||
* payload) → delete the row so it can't wedge the queue forever.
|
||||
* - RETRY — transient (transport, 5xx, 408, 429) → leave queued, bump
|
||||
* attempts, retry on the next trigger.
|
||||
*
|
||||
* Triggered by:
|
||||
* - app start (MinstrelApplication @Inject forces the singleton's
|
||||
* init block to subscribe)
|
||||
* - every time AuthStore.sessionCookie transitions to a non-null
|
||||
* value (sign-in, app launch with persisted cookie)
|
||||
* - app start (MinstrelApplication @Inject forces the singleton's init)
|
||||
* - AuthStore.sessionCookie transitioning to non-null (sign-in / cold
|
||||
* start with a persisted cookie)
|
||||
* - NetworkStatusController recovering to Healthy (server reachable again
|
||||
* mid-session — covers offline→online without needing a cold start)
|
||||
*
|
||||
* Single in-flight via the `mutex` — back-to-back triggers collapse
|
||||
* into one drain pass. A proper connectivity-listener / WorkManager
|
||||
* job that re-tries on Wi-Fi-came-back is a follow-up; for MVP the
|
||||
* sign-in + app-open triggers cover the common offline → online
|
||||
* transition (operator unlocks phone, opens app).
|
||||
*
|
||||
* Failed rows stay in the queue with their `attempts` and
|
||||
* `lastAttemptAt` updated. They get another chance on the next
|
||||
* trigger; no exponential backoff or max-attempts yet — the rows are
|
||||
* small (low single-digit count for typical use) so retry-forever is
|
||||
* cheap.
|
||||
* Single in-flight via the [mutex]; back-to-back triggers collapse into one
|
||||
* drain pass. No exponential backoff / max-attempts: the rows are few for
|
||||
* typical use, and permanent failures are now dropped rather than retried
|
||||
* forever.
|
||||
*/
|
||||
@Singleton
|
||||
class MutationReplayer @Inject constructor(
|
||||
@@ -60,6 +68,7 @@ class MutationReplayer @Inject constructor(
|
||||
private val authStore: AuthStore,
|
||||
private val json: Json,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
networkStatus: NetworkStatusController,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val likesApi: LikesApi = retrofit.create()
|
||||
@@ -72,6 +81,8 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private enum class Outcome { SENT, DROP, RETRY }
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
authStore.sessionCookie
|
||||
@@ -79,6 +90,16 @@ class MutationReplayer @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
.collect { drainSafe() }
|
||||
}
|
||||
// Server-reachable-again: replay queued writes the moment the
|
||||
// health signal recovers, so an offline→online transition mid-
|
||||
// session doesn't wait for the next cold start / re-auth.
|
||||
scope.launch {
|
||||
networkStatus.state
|
||||
.map { it == ServerHealth.Healthy }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { drainSafe() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
|
||||
@@ -93,193 +114,185 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private suspend fun drain() {
|
||||
val rows = dao.getAll()
|
||||
// Collapse superseded like-toggles: only the latest desired state per
|
||||
// (entity) is replayed; older toggles for the same entity are dropped
|
||||
// unsent. Without this, partial-failure + differential retry could
|
||||
// replay an older toggle last and invert the final like state.
|
||||
val superseded = supersededLikeToggleIds(rows)
|
||||
for (row in rows) {
|
||||
val sent = runCatching { dispatch(row) }.getOrDefault(false)
|
||||
if (sent) {
|
||||
if (row.id in superseded) {
|
||||
dao.delete(row.id)
|
||||
} else {
|
||||
dao.recordAttempt(row.id, Clock.System.now())
|
||||
continue
|
||||
}
|
||||
when (outcomeFor(row)) {
|
||||
Outcome.SENT, Outcome.DROP -> dao.delete(row.id)
|
||||
Outcome.RETRY -> dao.recordAttempt(row.id, Clock.System.now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
|
||||
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
|
||||
val latestByEntity = HashMap<String, Long>()
|
||||
val superseded = HashSet<Long>()
|
||||
rows.asSequence()
|
||||
.filter { it.kind == MutationKind.LIKE_TOGGLE }
|
||||
.forEach { row ->
|
||||
val decoded = runCatching {
|
||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()
|
||||
if (decoded != null) {
|
||||
val key = "${decoded.entityType}:${decoded.entityId}"
|
||||
// `rows` is ascending by id, so a prior entry is always older.
|
||||
latestByEntity.put(key, row.id)?.let(superseded::add)
|
||||
}
|
||||
}
|
||||
return superseded
|
||||
}
|
||||
|
||||
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
||||
dispatch(row)
|
||||
} catch (e: HttpException) {
|
||||
if (isPermanent(e.code())) Outcome.DROP else Outcome.RETRY
|
||||
} catch (@Suppress("SwallowedException") e: SerializationException) {
|
||||
// Corrupt / schema-incompatible payload — permanent, drop it.
|
||||
Outcome.DROP
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable) {
|
||||
// Transport / unknown — leave queued for the next pass.
|
||||
Outcome.RETRY
|
||||
}
|
||||
|
||||
/** 4xx are permanent except the two "retry me" statuses. */
|
||||
private fun isPermanent(code: Int): Boolean =
|
||||
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
||||
|
||||
/**
|
||||
* Returns true if the server accepted the call, false on any
|
||||
* failure (transport, server error, decode). The replayer treats
|
||||
* false as "leave the row for next pass" — it doesn't try to
|
||||
* distinguish permanent rejections from transient ones; a row
|
||||
* that consistently fails will accumulate attempt count and
|
||||
* lastAttemptAt for diagnostics.
|
||||
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
|
||||
* unknown kind/variant. Network / HTTP failures THROW and are classified
|
||||
* by [outcomeFor] — dispatchers no longer swallow.
|
||||
*/
|
||||
private suspend fun dispatch(row: CachedMutationEntity): Boolean = when (row.kind) {
|
||||
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
|
||||
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
||||
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
||||
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
||||
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
||||
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
||||
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
else -> {
|
||||
// Unknown kind — drop the row by claiming success so a
|
||||
// stale schema entry can't wedge the queue forever.
|
||||
true
|
||||
}
|
||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
|
||||
private suspend fun dispatchLikeToggle(payload: String): Boolean {
|
||||
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||
val kindPath = when (decoded.entityType) {
|
||||
LikesRepository.ENTITY_ARTIST -> "artists"
|
||||
LikesRepository.ENTITY_ALBUM -> "albums"
|
||||
LikesRepository.ENTITY_TRACK -> "tracks"
|
||||
else -> return true // unknown variant — drop.
|
||||
else -> return Outcome.DROP
|
||||
}
|
||||
return try {
|
||||
if (decoded.desiredState) {
|
||||
likesApi.like(kindPath, decoded.entityId)
|
||||
} else {
|
||||
likesApi.unlike(kindPath, decoded.entityId)
|
||||
}
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
if (decoded.desiredState) {
|
||||
likesApi.like(kindPath, decoded.entityId)
|
||||
} else {
|
||||
likesApi.unlike(kindPath, decoded.entityId)
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCreate(payload: String): Boolean {
|
||||
private suspend fun dispatchRequestCreate(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
|
||||
val body = CreateRequestBody(
|
||||
kind = decoded.kind,
|
||||
artistMbid = decoded.artistMbid,
|
||||
artistName = decoded.artistName,
|
||||
albumMbid = decoded.albumMbid,
|
||||
albumTitle = decoded.albumTitle,
|
||||
trackMbid = decoded.trackMbid,
|
||||
trackTitle = decoded.trackTitle,
|
||||
discoverApi.createRequest(
|
||||
CreateRequestBody(
|
||||
kind = decoded.kind,
|
||||
artistMbid = decoded.artistMbid,
|
||||
artistName = decoded.artistName,
|
||||
albumMbid = decoded.albumMbid,
|
||||
albumTitle = decoded.albumTitle,
|
||||
trackMbid = decoded.trackMbid,
|
||||
trackTitle = decoded.trackTitle,
|
||||
),
|
||||
)
|
||||
return try {
|
||||
discoverApi.createRequest(body)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchQuarantineUnflag(payload: String): Boolean {
|
||||
private suspend fun dispatchQuarantineUnflag(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
|
||||
return try {
|
||||
quarantineApi.unflag(decoded.trackId)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
}
|
||||
quarantineApi.unflag(decoded.trackId)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaylistAppend(payload: String): Boolean {
|
||||
private suspend fun dispatchPlaylistAppend(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
|
||||
return try {
|
||||
playlistsApi.appendTracks(
|
||||
playlistId = decoded.playlistId,
|
||||
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
playlistsApi.appendTracks(
|
||||
playlistId = decoded.playlistId,
|
||||
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchQuarantineFlag(payload: String): Boolean {
|
||||
private suspend fun dispatchQuarantineFlag(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
|
||||
return try {
|
||||
quarantineApi.flag(
|
||||
FlagRequest(
|
||||
trackId = decoded.trackId,
|
||||
reason = decoded.reason,
|
||||
notes = decoded.notes,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
quarantineApi.flag(
|
||||
FlagRequest(
|
||||
trackId = decoded.trackId,
|
||||
reason = decoded.reason,
|
||||
notes = decoded.notes,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlayOffline(payload: String): Boolean {
|
||||
private suspend fun dispatchPlayOffline(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
||||
return try {
|
||||
eventsApi.playOffline(
|
||||
PlayOfflineRequest(
|
||||
trackId = decoded.trackId,
|
||||
clientId = decoded.clientId,
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
eventsApi.playOffline(
|
||||
PlayOfflineRequest(
|
||||
trackId = decoded.trackId,
|
||||
clientId = decoded.clientId,
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCancel(payload: String): Boolean {
|
||||
private suspend fun dispatchPlayEnded(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlayEndedPayload.serializer(), payload)
|
||||
eventsApi.playEnded(
|
||||
PlayEndedRequest(
|
||||
playEventId = decoded.playEventId,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCancel(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
|
||||
return try {
|
||||
requestsApi.cancel(decoded.requestId)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
requestsApi.cancel(decoded.requestId)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Boolean {
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
return try {
|
||||
playbackErrorsApi.report(
|
||||
PlaybackErrorReportRequest(
|
||||
trackId = decoded.trackId,
|
||||
kind = decoded.kind,
|
||||
detail = decoded.detail,
|
||||
clientId = decoded.clientId,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
playbackErrorsApi.report(
|
||||
PlaybackErrorReportRequest(
|
||||
trackId = decoded.trackId,
|
||||
kind = decoded.kind,
|
||||
detail = decoded.detail,
|
||||
clientId = decoded.clientId,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val HTTP_CLIENT_ERR_MIN = 400
|
||||
const val HTTP_CLIENT_ERR_MAX = 499
|
||||
const val HTTP_TIMEOUT = 408
|
||||
const val HTTP_TOO_MANY = 429
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -2,6 +2,8 @@ package com.fabledsword.minstrel.cache.sync
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
@@ -16,8 +18,11 @@ import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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.datetime.Clock
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
@@ -50,10 +55,15 @@ class SyncController @Inject constructor(
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val authStore: AuthStore,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
networkStatus: NetworkStatusController,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: SyncApi = retrofit.create()
|
||||
|
||||
// Single in-flight guard: the cookie + health-recovery triggers (and a
|
||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||
private val mutex = Mutex()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
authStore.sessionCookie
|
||||
@@ -61,11 +71,25 @@ class SyncController @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
.collect { syncSafe() }
|
||||
}
|
||||
// Re-sync when the server becomes reachable again mid-session, so a
|
||||
// delta missed while offline lands without waiting for a cold start.
|
||||
scope.launch {
|
||||
networkStatus.state
|
||||
.map { it == ServerHealth.Healthy }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { syncSafe() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
||||
suspend fun syncSafe() {
|
||||
runCatching { sync() }
|
||||
if (!mutex.tryLock()) return
|
||||
try {
|
||||
runCatching { sync() }
|
||||
} finally {
|
||||
mutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sync() {
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ import javax.inject.Singleton
|
||||
* the app to Offline with no debounce and fast-failed in-flight playback
|
||||
* via [com.fabledsword.minstrel.player.OfflineGatedDataSource]. The
|
||||
* authority on whether *Minstrel* is reachable is the `/healthz` poll
|
||||
* ([com.fabledsword.minstrel.update.data.VersionCheckController], which
|
||||
* ([com.fabledsword.minstrel.connectivity.NetworkStatusController], which
|
||||
* has its own failure hysteresis), not this coarse device-link signal.
|
||||
*
|
||||
* Used by the shell-level ConnectionErrorBanner; downstream
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.update.api.HealthzApi
|
||||
import com.fabledsword.minstrel.update.api.HealthzResponse
|
||||
import com.fabledsword.minstrel.update.data.VersionResult
|
||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val POLL_HEALTHY_MS = 5 * 60 * 1000L
|
||||
private const val POLL_DEGRADED_MS = 20_000L
|
||||
private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
||||
|
||||
/**
|
||||
* THE single authority on network/server reachability. Absorbs the former
|
||||
* VersionCheckController (the /healthz poll + version parsing) and
|
||||
* ServerHealthController (the tri-state derive) into one signal-driven unit so
|
||||
* the app has one place that answers "can we reach Minstrel?" — not three
|
||||
* half-systems reporting differently.
|
||||
*
|
||||
* Inputs (all funnel through a single-consumer intent channel for thread
|
||||
* safety — [reportSuccess]/[reportFailure] are called from the audio read path
|
||||
* and OkHttp threads):
|
||||
* - device link transitions ([ConnectivityObserver]); link-return triggers an
|
||||
* immediate probe so recovery is near-instant (fixes the sticky banner).
|
||||
* - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
|
||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||
* source, and the playback-error reporter.
|
||||
* - recheck() from pull-to-refresh and the banner.
|
||||
*
|
||||
* Version compatibility is a byproduct of the same /healthz response.
|
||||
*
|
||||
* Constructed at launch via the construct-the-singleton trick in
|
||||
* [com.fabledsword.minstrel.MinstrelApplication].
|
||||
*/
|
||||
@Singleton
|
||||
class NetworkStatusController @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
connectivity: ConnectivityObserver,
|
||||
private val authStore: AuthStore,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||
private val machine = ReachabilityMachine()
|
||||
private val lastProbeAtMs = AtomicLong(0)
|
||||
|
||||
private val stateInternal = MutableStateFlow(ServerHealth.Healthy)
|
||||
val state: StateFlow<ServerHealth> = stateInternal.asStateFlow()
|
||||
|
||||
private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
|
||||
val versionResult: StateFlow<VersionResult> = versionInternal.asStateFlow()
|
||||
|
||||
private sealed interface Intent {
|
||||
data class Link(val up: Boolean) : Intent
|
||||
data class Probe(val resp: HealthzResponse?) : Intent
|
||||
object OpSuccess : Intent
|
||||
object OpFailure : Intent
|
||||
}
|
||||
|
||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
||||
|
||||
init {
|
||||
scope.launch { reduceLoop() }
|
||||
scope.launch {
|
||||
connectivity.online.collect { up ->
|
||||
intents.trySend(Intent.Link(up))
|
||||
if (up) probeOnce()
|
||||
}
|
||||
}
|
||||
scope.launch { pollLoop() }
|
||||
}
|
||||
|
||||
/** A real server op verifiably succeeded — self-proving recovery. Cheap; safe on hot paths. */
|
||||
fun reportSuccess() {
|
||||
if (stateInternal.value != ServerHealth.Healthy) intents.trySend(Intent.OpSuccess)
|
||||
}
|
||||
|
||||
/** A real network op failed — triggers /healthz arbitration. No-op when already Offline. */
|
||||
fun reportFailure() {
|
||||
if (stateInternal.value == ServerHealth.Offline) return
|
||||
intents.trySend(Intent.OpFailure)
|
||||
}
|
||||
|
||||
/** One-shot recheck for pull-to-refresh and the banner. */
|
||||
fun recheck() {
|
||||
scope.launch { probeOnce(force = true) }
|
||||
}
|
||||
|
||||
private suspend fun reduceLoop() {
|
||||
for (intent in intents) {
|
||||
val now = System.currentTimeMillis()
|
||||
when (intent) {
|
||||
is Intent.Link -> machine.onLinkChange(intent.up)
|
||||
is Intent.Probe -> applyProbe(intent.resp, now)
|
||||
Intent.OpSuccess -> machine.onSuccess()
|
||||
Intent.OpFailure -> {
|
||||
machine.onOpFailure(now)
|
||||
// Arbitrate off the reducer thread: awaiting a stalled
|
||||
// /healthz here would block a concurrent self-proving
|
||||
// success from snapping us straight back to Healthy.
|
||||
scope.launch { probeOnce() }
|
||||
}
|
||||
}
|
||||
emit(machine.health())
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyProbe(resp: HealthzResponse?, now: Long) {
|
||||
if (resp == null) {
|
||||
machine.onProbeFailure(now)
|
||||
} else {
|
||||
machine.onSuccess()
|
||||
versionInternal.value = versionResultFor(resp)
|
||||
}
|
||||
}
|
||||
|
||||
private fun emit(next: ServerHealth) {
|
||||
if (stateInternal.value != next) {
|
||||
Timber.w("NetworkStatus -> %s", next)
|
||||
stateInternal.value = next
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollLoop() {
|
||||
probeOnce()
|
||||
while (true) {
|
||||
val interval =
|
||||
if (stateInternal.value == ServerHealth.Healthy) POLL_HEALTHY_MS
|
||||
else POLL_DEGRADED_MS
|
||||
delay(interval)
|
||||
probeOnce()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun probeOnce(force: Boolean = false) {
|
||||
// Startup guard: don't poll the localhost placeholder before AuthStore
|
||||
// hydrates the real base URL — that false failure used to flash the banner.
|
||||
if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
|
||||
val now = System.currentTimeMillis()
|
||||
if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
|
||||
lastProbeAtMs.set(now)
|
||||
val resp = runCatching { api.check() }.getOrNull()
|
||||
intents.trySend(Intent.Probe(resp))
|
||||
}
|
||||
|
||||
private fun versionResultFor(resp: HealthzResponse): VersionResult {
|
||||
val min = resp.minClientVersion
|
||||
return when {
|
||||
min.isEmpty() -> VersionResult.SKIPPED
|
||||
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
|
||||
else -> VersionResult.OK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive [ServerHealth] snapshot provided once at the app root. Lets leaf
|
||||
* composables (TrackRow gating, write-affordance disabling) branch on health
|
||||
* without re-injecting the controller. Defaults to Healthy so previews/tests
|
||||
* don't crash.
|
||||
*/
|
||||
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
internal const val ESCALATE_AFTER_MS = 120_000L
|
||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||
|
||||
/**
|
||||
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||
* testable. [NetworkStatusController] wires real time + signals around it.
|
||||
*
|
||||
* Reachability (independent of the device link):
|
||||
* - Reachable last evidence says the server answered.
|
||||
* - Unstable a probe failed; arbitration/escalation pending.
|
||||
* - Unreachable corroborated or sustained failure.
|
||||
*
|
||||
* [health] folds the device link over that: no link → Offline; otherwise the
|
||||
* reachability maps Reachable→Healthy, Unstable→Unstable, Unreachable→ServerDown.
|
||||
*
|
||||
* Principle: **success is self-proving, failure is ambiguous.** [onSuccess]
|
||||
* (a real server byte-read or API 2xx, or a successful /healthz) snaps straight
|
||||
* back to Reachable. A failure only escalates when a /healthz probe corroborates
|
||||
* it ([onProbeFailure]) — either via fresh op-failure corroboration or the
|
||||
* sustained-time backstop.
|
||||
*/
|
||||
class ReachabilityMachine {
|
||||
|
||||
private enum class Reachability { Reachable, Unstable, Unreachable }
|
||||
|
||||
private var linkUp = true
|
||||
private var reachability = Reachability.Reachable
|
||||
private var failureStreakStartMs: Long? = null
|
||||
private val recentOpFailures = ArrayDeque<Long>()
|
||||
|
||||
fun onLinkChange(up: Boolean) {
|
||||
linkUp = up
|
||||
// Link transitions don't reset reachability — a restored link keeps the
|
||||
// last-known server reachability until a fresh probe/op result arrives.
|
||||
if (!up) recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/** A real successful server op (stream read, API 2xx) or a successful /healthz. */
|
||||
fun onSuccess() {
|
||||
reachability = Reachability.Reachable
|
||||
failureStreakStartMs = null
|
||||
recentOpFailures.clear()
|
||||
}
|
||||
|
||||
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
||||
fun onOpFailure(nowMs: Long) {
|
||||
pruneOpFailures(nowMs)
|
||||
recentOpFailures.addLast(nowMs)
|
||||
}
|
||||
|
||||
/** A /healthz probe failed — the arbiter. Escalates per corroboration/backstop. */
|
||||
fun onProbeFailure(nowMs: Long) {
|
||||
pruneOpFailures(nowMs)
|
||||
if (reachability == Reachability.Reachable) {
|
||||
reachability = Reachability.Unstable
|
||||
failureStreakStartMs = nowMs
|
||||
}
|
||||
if (reachability == Reachability.Unstable && shouldEscalate(nowMs)) {
|
||||
reachability = Reachability.Unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fun health(): ServerHealth = when {
|
||||
!linkUp -> ServerHealth.Offline
|
||||
reachability == Reachability.Reachable -> ServerHealth.Healthy
|
||||
reachability == Reachability.Unstable -> ServerHealth.Unstable
|
||||
else -> ServerHealth.ServerDown
|
||||
}
|
||||
|
||||
private fun shouldEscalate(nowMs: Long): Boolean {
|
||||
val corroborated = recentOpFailures.size >= CORROBORATION_OP_THRESHOLD
|
||||
val sustained =
|
||||
failureStreakStartMs?.let { nowMs - it >= ESCALATE_AFTER_MS } ?: false
|
||||
return corroborated || sustained
|
||||
}
|
||||
|
||||
private fun pruneOpFailures(nowMs: Long) {
|
||||
while (recentOpFailures.isNotEmpty() &&
|
||||
nowMs - recentOpFailures.first() > CORROBORATION_WINDOW_MS
|
||||
) {
|
||||
recentOpFailures.removeFirst()
|
||||
}
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import com.fabledsword.minstrel.api.BaseUrlInterceptor.Companion.PLACEHOLDER_HOST
|
||||
import dagger.Lazy
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val HEALTHZ_PATH = "/healthz"
|
||||
|
||||
/**
|
||||
* Feeds real Minstrel API outcomes into [NetworkStatusController]. A 2xx is
|
||||
* self-proving proof the server is reachable → reportSuccess(); a transport
|
||||
* [IOException] (no response at all) → reportFailure(), which triggers /healthz
|
||||
* arbitration.
|
||||
*
|
||||
* MUST run first in the OkHttp chain (before [BaseUrlInterceptor]) so the host
|
||||
* is still the [PLACEHOLDER_HOST] sentinel: this shared client also fetches
|
||||
* EXTERNAL artwork (musicbrainz / coverartarchive), and an external image
|
||||
* loading must NOT be read as "our server is reachable" — only sentinel-host
|
||||
* requests are Minstrel-bound. 5xx is deliberately NOT a failure (the server
|
||||
* answered), and /healthz is skipped to avoid a feedback loop with the poll.
|
||||
*
|
||||
* [NetworkStatusController] is injected as a [Lazy] to break the Hilt cycle:
|
||||
* the controller needs `Retrofit`, which needs `OkHttpClient`, which needs this
|
||||
* interceptor. By the time a request flows through, the controller singleton is
|
||||
* already constructed (construct-the-singleton trick in MinstrelApplication).
|
||||
*/
|
||||
@Singleton
|
||||
class ReachabilityReportingInterceptor @Inject constructor(
|
||||
private val networkStatus: Lazy<NetworkStatusController>,
|
||||
) : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val isMinstrel = request.url.host == PLACEHOLDER_HOST
|
||||
val isHealthz = request.url.encodedPath.endsWith(HEALTHZ_PATH)
|
||||
if (!isMinstrel || isHealthz) return chain.proceed(request)
|
||||
return try {
|
||||
val response = chain.proceed(request)
|
||||
if (response.isSuccessful) networkStatus.get().reportSuccess()
|
||||
response
|
||||
} catch (e: IOException) {
|
||||
networkStatus.get().reportFailure()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
/**
|
||||
* The single reachability signal every consumer branches on.
|
||||
*
|
||||
* - [Healthy] link up, /healthz ok — normal network behavior.
|
||||
* - [Unstable] link up, a recent failure with arbitration pending —
|
||||
* INFORMATIONAL ONLY. Does NOT gate playback; preserves the
|
||||
* anti-flicker intent of commit 5c0db429 while still warning
|
||||
* the user that something is flaky.
|
||||
* - [ServerDown] link up but /healthz failing, corroborated or sustained —
|
||||
* gate to cache-only.
|
||||
* - [Offline] no device link at all — gate to cache-only.
|
||||
*/
|
||||
enum class ServerHealth { Healthy, Unstable, ServerDown, Offline }
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Tri-state server-reachability signal that downstream consumers can branch
|
||||
* on to decide whether to hit the network, gate writes, or fall back to
|
||||
* cache-only behavior.
|
||||
*
|
||||
* Composed from two existing signals -- this controller doesn't poll its own
|
||||
* endpoint:
|
||||
*
|
||||
* - [ConnectivityObserver.online] -- system-level NetworkCallback that
|
||||
* answers only "is there an active INTERNET-capable network link" (NOT
|
||||
* VALIDATED -- a WAN-validation flicker must not read as offline when
|
||||
* the LAN server is reachable).
|
||||
* - [VersionCheckController.reachable] -- did the last `/healthz` poll
|
||||
* succeed. This is the authority on whether *Minstrel* is reachable;
|
||||
* it has its own failure hysteresis. Distinguishes "device has a link
|
||||
* but our server is down" from "no network at all."
|
||||
*
|
||||
* `version too old` is intentionally *not* folded in here -- it's a separate
|
||||
* UX (the VersionTooOldBanner) and conflating it with offline would mask the
|
||||
* real cause.
|
||||
*/
|
||||
enum class ServerHealth { Healthy, Offline, ServerDown }
|
||||
|
||||
@Singleton
|
||||
class ServerHealthController @Inject constructor(
|
||||
@ApplicationScope scope: CoroutineScope,
|
||||
connectivity: ConnectivityObserver,
|
||||
versionCheck: VersionCheckController,
|
||||
) {
|
||||
val state: StateFlow<ServerHealth> = combine(
|
||||
connectivity.online,
|
||||
versionCheck.reachable,
|
||||
) { online, serverReachable ->
|
||||
when {
|
||||
!online -> ServerHealth.Offline
|
||||
!serverReachable -> ServerHealth.ServerDown
|
||||
else -> ServerHealth.Healthy
|
||||
}
|
||||
}
|
||||
// Transition log -- the signal had no instrumentation, which is why a
|
||||
// false-offline (WAN flicker flipping playback to "Source error") was
|
||||
// hard to diagnose from logcat. WARN-tier so ReleaseTree surfaces it.
|
||||
.distinctUntilChanged()
|
||||
.onEach { Timber.w("ServerHealth -> %s", it) }
|
||||
.stateIn(
|
||||
scope = scope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = ServerHealth.Healthy,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive [ServerHealth] snapshot provided once at the app root from the
|
||||
* controller's StateFlow. Lets leaf composables (TrackRow gating, write-
|
||||
* affordance disabling) branch on health without each ViewModel re-
|
||||
* injecting the controller. Defaults to Healthy so unwrapped previews
|
||||
* and tests don't crash.
|
||||
*/
|
||||
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
|
||||
+81
-43
@@ -14,83 +14,121 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.composables.icons.lucide.CloudOff
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val HEALTH_SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
private const val BACK_ONLINE_FLASH_MS = 2_000L
|
||||
|
||||
/**
|
||||
* Lifts [ServerHealthController]'s tri-state into a StateFlow for the banner
|
||||
* composable. Keeps the banner pure-presentation.
|
||||
* Exposes [NetworkStatusController]'s tri-state directly to the banner. No
|
||||
* re-wrapping StateFlow — the controller's is already app-scoped and warm.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class ConnectivityBannerViewModel @Inject constructor(
|
||||
health: ServerHealthController,
|
||||
networkStatus: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val health: StateFlow<ServerHealth> = health.state.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(HEALTH_SHARE_STOP_TIMEOUT_MS),
|
||||
initialValue = ServerHealth.Healthy,
|
||||
)
|
||||
val health: StateFlow<ServerHealth> = networkStatus.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner shown at the top of the shell when the user can't reach the server.
|
||||
* Tri-state so we tell the user *why*: no device network vs server-down.
|
||||
* Copy choices match the Flutter analogues. Auto-hides via slide+fade when
|
||||
* health returns to [ServerHealth.Healthy].
|
||||
* Shell banner. Tells the user *why* they're degraded — no link vs server-down
|
||||
* — plus a non-alarming "Reconnecting…" for the transient [ServerHealth.Unstable]
|
||||
* window, and a brief "Back online" confirmation when health recovers so
|
||||
* recovery is unmistakable. Sentence case, understated voice (design system).
|
||||
*/
|
||||
@Composable
|
||||
fun ConnectionErrorBanner(
|
||||
viewModel: ConnectivityBannerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val health by viewModel.health.collectAsStateWithLifecycle()
|
||||
var showBackOnline by remember { mutableStateOf(false) }
|
||||
var wasDown by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(health) {
|
||||
val down = health == ServerHealth.Offline || health == ServerHealth.ServerDown
|
||||
if (health == ServerHealth.Healthy && wasDown) {
|
||||
// try/finally so a mid-delay cancellation (health flips again) can't
|
||||
// orphan the flag and leave "Back online" stuck on screen.
|
||||
try {
|
||||
showBackOnline = true
|
||||
delay(BACK_ONLINE_FLASH_MS)
|
||||
} finally {
|
||||
showBackOnline = false
|
||||
}
|
||||
}
|
||||
wasDown = down
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = health != ServerHealth.Healthy,
|
||||
visible = health != ServerHealth.Healthy || showBackOnline,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Lucide.CloudOff,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
text = when (health) {
|
||||
ServerHealth.Offline ->
|
||||
"No connection — check Wi-Fi or mobile data."
|
||||
ServerHealth.ServerDown ->
|
||||
"Server unreachable — your cached content is still available."
|
||||
ServerHealth.Healthy -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
BannerContent(health = health, backOnline = showBackOnline)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerContent(health: ServerHealth, backOnline: Boolean) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
val background: Color
|
||||
val foreground: Color
|
||||
when {
|
||||
backOnline -> {
|
||||
background = scheme.secondaryContainer
|
||||
foreground = scheme.onSecondaryContainer
|
||||
}
|
||||
health == ServerHealth.Unstable -> {
|
||||
background = scheme.surfaceVariant
|
||||
foreground = scheme.onSurfaceVariant
|
||||
}
|
||||
else -> {
|
||||
background = scheme.errorContainer
|
||||
foreground = scheme.onErrorContainer
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(background)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Icon(imageVector = Lucide.CloudOff, contentDescription = null, tint = foreground)
|
||||
Text(
|
||||
text = bannerText(health, backOnline),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = foreground,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun bannerText(health: ServerHealth, backOnline: Boolean): String = when {
|
||||
backOnline -> "Back online."
|
||||
health == ServerHealth.Offline -> "No connection — check Wi-Fi or mobile data."
|
||||
health == ServerHealth.ServerDown ->
|
||||
"Server unreachable — your cached content is still available."
|
||||
health == ServerHealth.Unstable -> "Reconnecting…"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ import javax.inject.Singleton
|
||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
||||
*/
|
||||
@Singleton
|
||||
// Per-section observe accessors (one per Home row) inflate the function
|
||||
// count past detekt's 11 default — accessor density, not complexity.
|
||||
@Suppress("TooManyFunctions")
|
||||
class HomeRepository @Inject constructor(
|
||||
private val homeIndexDao: CachedHomeIndexDao,
|
||||
private val metadataProvider: MetadataProvider,
|
||||
@@ -76,6 +79,12 @@ class HomeRepository @Inject constructor(
|
||||
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
|
||||
|
||||
fun observeYouMightLikeAlbums(): Flow<List<HomeTile<AlbumRef>>> =
|
||||
observeAlbumSection(SECTION_YOU_MIGHT_LIKE_ALBUMS)
|
||||
|
||||
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
||||
|
||||
/**
|
||||
* Pulls /api/home/index, replaces each cached_home_index section,
|
||||
* and pre-warms the top artists. The section Flows re-fire on the
|
||||
@@ -88,7 +97,11 @@ class HomeRepository @Inject constructor(
|
||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
||||
prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
|
||||
prewarmer.warm(
|
||||
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
||||
@@ -148,5 +161,7 @@ class HomeRepository @Inject constructor(
|
||||
const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists"
|
||||
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
||||
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
||||
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import com.composables.icons.lucide.History
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
@@ -94,6 +95,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -115,6 +117,8 @@ data class HomeSections(
|
||||
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
|
||||
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
|
||||
val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
) {
|
||||
val isAllEmpty: Boolean
|
||||
get() = playlists.isEmpty() &&
|
||||
@@ -122,7 +126,9 @@ data class HomeSections(
|
||||
rediscoverAlbums.isEmpty() &&
|
||||
rediscoverArtists.isEmpty() &&
|
||||
mostPlayedTracks.isEmpty() &&
|
||||
lastPlayedArtists.isEmpty()
|
||||
lastPlayedArtists.isEmpty() &&
|
||||
youMightLikeAlbums.isEmpty() &&
|
||||
youMightLikeArtists.isEmpty()
|
||||
}
|
||||
|
||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||
@@ -134,7 +140,8 @@ class HomeViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||
connectivity: com.fabledsword.minstrel.connectivity.ConnectivityObserver,
|
||||
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
|
||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val systemStatusInternal = MutableStateFlow(SystemPlaylistsStatus())
|
||||
@@ -142,9 +149,13 @@ class HomeViewModel @Inject constructor(
|
||||
/** System-playlist build status for the Home placeholder cards. */
|
||||
val systemStatus: StateFlow<SystemPlaylistsStatus> = systemStatusInternal.asStateFlow()
|
||||
|
||||
/** True when the device has no usable internet — gates the offline-pool cards. */
|
||||
val offline: StateFlow<Boolean> = connectivity.online
|
||||
.map { !it }
|
||||
/**
|
||||
* Cache-only when there's no link OR the server is unreachable. Reads the
|
||||
* unified [NetworkStatusController] (not the raw device link) so Home reacts
|
||||
* to ServerDown too; the transient Unstable state stays calm (not offline).
|
||||
*/
|
||||
val offline: StateFlow<Boolean> = networkStatus.state
|
||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
@@ -158,6 +169,15 @@ class HomeViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,13 +304,14 @@ class HomeViewModel @Inject constructor(
|
||||
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
||||
|
||||
/**
|
||||
* Five home-section flows merged into the sections struct without
|
||||
* playlists; then merged with the playlists flow in [combineHomeFlows].
|
||||
* Splitting it like this keeps each combine call inside the
|
||||
* coroutines built-in arity limit (5) and avoids the typed-vararg
|
||||
* acrobatics needed for a 6-flow combine across heterogeneous types.
|
||||
* The core five section flows merged into the sections struct. Split
|
||||
* out so each combine call stays inside the coroutines built-in arity
|
||||
* limit (5) and avoids the typed-vararg acrobatics a wider combine
|
||||
* across heterogeneous types would need. [observeHomeSections] then
|
||||
* folds in the two you-might-like flows, and [combineHomeFlows] the
|
||||
* playlists flow.
|
||||
*/
|
||||
private fun observeHomeSections() = combine(
|
||||
private fun observeCoreSections() = combine(
|
||||
homeRepository.observeRecentlyAddedAlbums(),
|
||||
homeRepository.observeRediscoverAlbums(),
|
||||
homeRepository.observeRediscoverArtists(),
|
||||
@@ -306,6 +327,14 @@ class HomeViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeHomeSections() = combine(
|
||||
observeCoreSections(),
|
||||
homeRepository.observeYouMightLikeAlbums(),
|
||||
homeRepository.observeYouMightLikeArtists(),
|
||||
) { core, ymlAlbums, ymlArtists ->
|
||||
core.copy(youMightLikeAlbums = ymlAlbums, youMightLikeArtists = ymlArtists)
|
||||
}
|
||||
|
||||
private fun combineHomeFlows() =
|
||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
||||
val merged = sections.copy(playlists = playlists)
|
||||
@@ -458,6 +487,14 @@ private fun HomeSuccessContent(
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
youMightLikeSection(
|
||||
albums = sections.youMightLikeAlbums,
|
||||
artists = sections.youMightLikeArtists,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlayAlbum = onPlayAlbum,
|
||||
onPlayArtist = onPlayArtist,
|
||||
)
|
||||
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
||||
rediscoverSection(
|
||||
albums = sections.rediscoverAlbums,
|
||||
@@ -662,6 +699,61 @@ private fun RediscoverBlock(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "You might like" — in-library albums/artists predicted from the user's
|
||||
* taste that they don't actively spin (server section, daily-built, cold-
|
||||
* start gated). Sits directly under the system-playlists row. Same
|
||||
* albums-then-artists block shape as Rediscover; the artist sub-row drops
|
||||
* its title when an album sub-row precedes it so the two read as one section.
|
||||
*/
|
||||
private fun LazyListScope.youMightLikeSection(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
artists: List<HomeTile<ArtistRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onPlayAlbum: suspend (String) -> Unit,
|
||||
onPlayArtist: suspend (String) -> Unit,
|
||||
) {
|
||||
item {
|
||||
if (albums.isNotEmpty() || artists.isNotEmpty()) {
|
||||
YouMightLikeBlock(
|
||||
albums = albums,
|
||||
artists = artists,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlayAlbum = onPlayAlbum,
|
||||
onPlayArtist = onPlayArtist,
|
||||
)
|
||||
} else {
|
||||
EmptySection(
|
||||
title = "You might like",
|
||||
body = "We're still learning your taste — keep listening and " +
|
||||
"picks you might like will show up here.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YouMightLikeBlock(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
artists: List<HomeTile<ArtistRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onPlayAlbum: suspend (String) -> Unit,
|
||||
onPlayArtist: suspend (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
if (albums.isNotEmpty()) {
|
||||
AlbumsRow("You might like", albums, onAlbumClick, onPlayAlbum)
|
||||
}
|
||||
if (artists.isNotEmpty()) {
|
||||
val title = if (albums.isEmpty()) "You might like" else ""
|
||||
ArtistsRow(title, artists, onArtistClick, onPlayArtist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumsRow(
|
||||
title: String,
|
||||
@@ -701,18 +793,19 @@ private fun PlaylistsRow(
|
||||
icon = iconForPool(item.kind),
|
||||
onClick = { onPlayPool(item.kind) },
|
||||
)
|
||||
is PlaylistRowItem.Real -> PlaylistCard(
|
||||
playlist = item.playlist,
|
||||
onClick = { onPlaylistClick(item.playlist.id) },
|
||||
onPlay = { onPlayPlaylist(item.playlist) },
|
||||
// Match Flutter: refreshable system playlists need
|
||||
// the live server endpoints, so disable their play
|
||||
// overlay when offline (user can still tap into the
|
||||
// detail and shuffle all from cache). User playlists
|
||||
// play from cache + survive offline.
|
||||
playEnabled = item.playlist.trackCount > 0 &&
|
||||
!(offline && item.playlist.refreshable),
|
||||
)
|
||||
is PlaylistRowItem.Real -> {
|
||||
// Greyed offline when the tile needs the live server or
|
||||
// isn't fully cached — dimmed but still tappable into the
|
||||
// detail to shuffle whatever subset is cached.
|
||||
val greyed = offline && item.playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = item.playlist,
|
||||
onClick = { onPlaylistClick(item.playlist.id) },
|
||||
onPlay = { onPlayPlaylist(item.playlist) },
|
||||
playEnabled = item.playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
is PlaylistRowItem.Placeholder -> PlaylistPlaceholderCard(
|
||||
label = item.label,
|
||||
variant = item.variant,
|
||||
@@ -728,7 +821,7 @@ private fun iconForPool(kind: OfflinePoolKind) = when (kind) {
|
||||
}
|
||||
|
||||
/** A cache-backed offline pool, a real playlist tile, or a not-yet-generated slot. */
|
||||
private sealed interface PlaylistRowItem {
|
||||
internal sealed interface PlaylistRowItem {
|
||||
data class OfflinePool(val kind: OfflinePoolKind) : PlaylistRowItem
|
||||
data class Real(val playlist: PlaylistRef) : PlaylistRowItem
|
||||
data class Placeholder(val label: String, val variant: String) : PlaylistRowItem
|
||||
@@ -741,55 +834,81 @@ enum class OfflinePoolKind(val label: String) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the Home Playlists row. When [offline], the two cache-backed
|
||||
* pool cards (Recently played, Liked) lead the row. Then For You +
|
||||
* Discover + 3× Songs-like fixed slots (real card when generated,
|
||||
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||
* rediscover / new for you / on this day / first listens) when they
|
||||
* exist — no placeholders for these since they're conditional on
|
||||
* library shape, not guaranteed singletons. Finally user-owned
|
||||
* playlists.
|
||||
* Builds the Home Playlists row.
|
||||
*
|
||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
||||
* no placeholders for these since they're conditional on library shape — then
|
||||
* user-owned playlists.
|
||||
*
|
||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||
* same real playlists in curated order but stably partitioned fully-cached
|
||||
* first / greyed after, and the "building/pending" placeholders are dropped
|
||||
* (they need the server to generate, so they're meaningless offline).
|
||||
*
|
||||
* Diverges from Flutter (`flutter_client/lib/library/home_screen.dart`
|
||||
* `_buildPlaylistsRow`) which only shows the 5 fixed slots and never
|
||||
* surfaces the secondary kinds on Home. Operator authorized the
|
||||
* divergence on 2026-06-01; web UI catch-up tracked as task #53.
|
||||
*/
|
||||
private fun buildPlaylistsRow(
|
||||
internal fun buildPlaylistsRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
offline: Boolean,
|
||||
): List<PlaylistRowItem> {
|
||||
if (!offline) return buildOnlineRow(owned, status)
|
||||
val out = mutableListOf<PlaylistRowItem>(
|
||||
PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED),
|
||||
PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED),
|
||||
)
|
||||
val (available, greyed) = orderedRealPlaylists(owned).partition { !it.unavailableOffline }
|
||||
(available + greyed).forEach { out += PlaylistRowItem.Real(it) }
|
||||
return out
|
||||
}
|
||||
|
||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
||||
private fun buildOnlineRow(
|
||||
owned: List<PlaylistRef>,
|
||||
status: SystemPlaylistsStatus,
|
||||
): List<PlaylistRowItem> {
|
||||
val out = mutableListOf<PlaylistRowItem>()
|
||||
if (offline) {
|
||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.RECENTLY_PLAYED)
|
||||
out += PlaylistRowItem.OfflinePool(OfflinePoolKind.LIKED)
|
||||
}
|
||||
out += owned.firstOrNull { it.systemVariant == "for_you" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("For You", variantFor("for-you", status))
|
||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(3)
|
||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
||||
out += songsLike.getOrNull(i)
|
||||
?.let { PlaylistRowItem.Real(it) }
|
||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||
}
|
||||
// Secondary system kinds in server-registry order. Only included
|
||||
// when actually generated — these depend on library shape (Deep
|
||||
// cuts needs deep albums, On this day needs prior history, etc.)
|
||||
// so a missing one means "not enough data" rather than "still
|
||||
// building".
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }
|
||||
?.let { out += PlaylistRowItem.Real(it) }
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||
}
|
||||
owned.filter { it.systemVariant == null }.forEach { out += PlaylistRowItem.Real(it) }
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||
* only differs by dropping placeholders + partitioning available-first.
|
||||
*/
|
||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||
val out = mutableListOf<PlaylistRef>()
|
||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||
}
|
||||
owned.filter { it.systemVariant == null }.forEach { out += it }
|
||||
return out
|
||||
}
|
||||
|
||||
private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
||||
s.inFlight -> "building"
|
||||
s.lastError != null -> "failed"
|
||||
|
||||
@@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor(
|
||||
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
||||
api.getArtistTracks(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls related artists for the ArtistDetail "Similar artists"
|
||||
* strip. Network-only — non-critical UI, callers swallow failures
|
||||
* and render nothing rather than blocking the screen.
|
||||
*/
|
||||
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
|
||||
api.getSimilarArtists(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
|
||||
* panel. Network-only; tapping a row plays this exact list as a
|
||||
* queue, so no persistence is needed.
|
||||
*/
|
||||
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
|
||||
api.getArtistTopTracks(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
||||
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
||||
|
||||
+116
-13
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
||||
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items as lazyItems
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -46,21 +49,27 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.User
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.models.albumCoverPath
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -102,23 +111,35 @@ fun ArtistDetailScreen(
|
||||
title = "Couldn't load artist",
|
||||
body = s.message,
|
||||
)
|
||||
is ArtistDetailUiState.Success -> {
|
||||
val artistLiked by viewModel.artistLiked
|
||||
.collectAsStateWithLifecycle()
|
||||
ArtistBody(
|
||||
detail = s.detail,
|
||||
artistLiked = artistLiked,
|
||||
onPlay = viewModel::playArtist,
|
||||
onToggleLike = viewModel::toggleLikeArtist,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
)
|
||||
}
|
||||
is ArtistDetailUiState.Success ->
|
||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistSuccessBody(
|
||||
state: ArtistDetailUiState.Success,
|
||||
viewModel: ArtistDetailViewModel,
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
|
||||
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
|
||||
ArtistBody(
|
||||
state = state,
|
||||
artistLiked = artistLiked,
|
||||
playingTrackId = playerState.currentTrack?.id,
|
||||
onPlay = viewModel::playArtist,
|
||||
onToggleLike = viewModel::toggleLikeArtist,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onTopTrackClick = viewModel::playTopTracks,
|
||||
onSimilarArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
is ArtistDetailUiState.Success -> state.detail.artist.name
|
||||
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
|
||||
@@ -127,12 +148,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
|
||||
@Composable
|
||||
private fun ArtistBody(
|
||||
detail: ArtistDetailRef,
|
||||
state: ArtistDetailUiState.Success,
|
||||
artistLiked: Boolean,
|
||||
playingTrackId: String?,
|
||||
onPlay: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onTopTrackClick: (Int) -> Unit,
|
||||
onSimilarArtistClick: (String) -> Unit,
|
||||
) {
|
||||
val detail = state.detail
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -149,6 +174,23 @@ private fun ArtistBody(
|
||||
onToggleLike = onToggleLike,
|
||||
)
|
||||
}
|
||||
if (state.topTracks.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
TopTracksPanel(
|
||||
tracks = state.topTracks,
|
||||
playingTrackId = playingTrackId,
|
||||
onTrackClick = onTopTrackClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.similarArtists.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SimilarArtistsStrip(
|
||||
artists = state.similarArtists,
|
||||
onArtistClick = onSimilarArtistClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (detail.albums.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyAlbumsHint()
|
||||
@@ -161,6 +203,67 @@ private fun ArtistBody(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopTracksPanel(
|
||||
tracks: List<TrackRef>,
|
||||
playingTrackId: String?,
|
||||
onTrackClick: (Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Top tracks",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
tracks.forEachIndexed { index, track ->
|
||||
TopTrackRow(
|
||||
track = track,
|
||||
nowPlaying = track.id == playingTrackId,
|
||||
onClick = { onTrackClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopTrackRow(
|
||||
track: TrackRef,
|
||||
nowPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
TrackRow(
|
||||
title = track.title,
|
||||
artist = track.artistName,
|
||||
trackId = track.id,
|
||||
onClick = onClick,
|
||||
nowPlaying = nowPlaying,
|
||||
trailing = {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec, zero = "--:--"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimilarArtistsStrip(
|
||||
artists: List<ArtistRef>,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = "Similar artists") {
|
||||
lazyItems(items = artists, key = { it.id }) { artist ->
|
||||
ArtistCard(
|
||||
artist = artist,
|
||||
onClick = { onArtistClick(artist.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistHeader(
|
||||
artist: ArtistRef,
|
||||
|
||||
+49
-1
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
@@ -23,13 +24,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
sealed interface ArtistDetailUiState {
|
||||
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
||||
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
|
||||
data class Success(
|
||||
val detail: ArtistDetailRef,
|
||||
val similarArtists: List<ArtistRef> = emptyList(),
|
||||
val topTracks: List<TrackRef> = emptyList(),
|
||||
) : ArtistDetailUiState
|
||||
data class Error(val message: String) : ArtistDetailUiState
|
||||
}
|
||||
|
||||
@@ -82,6 +88,7 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
try {
|
||||
val detail = repository.refreshArtistDetail(artistId)
|
||||
internal.value = ArtistDetailUiState.Success(detail)
|
||||
loadSecondarySections()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
@@ -89,6 +96,31 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the non-critical "Similar artists" + "Top tracks" sections
|
||||
* and folds them into the live Success state. Each failure is
|
||||
* swallowed (logged only) — these sections render absent rather than
|
||||
* blocking, so the core artist view never depends on them.
|
||||
*/
|
||||
private suspend fun loadSecondarySections() {
|
||||
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
|
||||
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
|
||||
val current = internal.value
|
||||
if (current is ArtistDetailUiState.Success) {
|
||||
internal.value = current.copy(similarArtists = similar, topTracks = top)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
|
||||
try {
|
||||
block()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every track across the artist's albums, shuffle, and start
|
||||
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
||||
@@ -118,4 +150,20 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the loaded "Top tracks" list starting at [index] — the top
|
||||
* tracks are themselves the playable queue (mirrors the web client's
|
||||
* sectionTracks = top-tracks behaviour). No network round-trip; the
|
||||
* list is already in the Success state.
|
||||
*/
|
||||
fun playTopTracks(index: Int) {
|
||||
val tracks = (internal.value as? ArtistDetailUiState.Success)?.topTracks.orEmpty()
|
||||
if (tracks.isEmpty()) return
|
||||
player.setQueue(
|
||||
tracks = tracks,
|
||||
initialIndex = index.coerceIn(0, tracks.lastIndex),
|
||||
source = "artist-top:$artistId",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ data class PlaylistRef(
|
||||
val trackCount: Int = 0,
|
||||
val coverUrl: String = "",
|
||||
val ownerUsername: String = "",
|
||||
/** All member tracks resident in the audio cache — playable fully offline. */
|
||||
val fullyCached: Boolean = false,
|
||||
) {
|
||||
val isSystem: Boolean get() = systemVariant != null
|
||||
|
||||
@@ -30,6 +32,13 @@ data class PlaylistRef(
|
||||
* expose a refresh trigger; rebuilds are scheduler-driven).
|
||||
*/
|
||||
val refreshable: Boolean get() = isSystem && systemVariant != "songs_like_artist"
|
||||
|
||||
/**
|
||||
* Can't be relied on while offline — either it needs the live server to
|
||||
* (re)generate (a refreshable system mix) or not all its tracks are cached.
|
||||
* Callers combine this with the current offline state to grey the tile.
|
||||
*/
|
||||
val unavailableOffline: Boolean get() = refreshable || !fullyCached
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
package com.fabledsword.minstrel.models.wire
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -15,7 +19,11 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlayStartedRequest(
|
||||
val type: String = "play_started",
|
||||
// Discriminator MUST stay on the wire. The app's Json has
|
||||
// encodeDefaults=false, so without this a `type` left at its
|
||||
// default is omitted entirely and the server 400s the event as
|
||||
// "unknown event type" — silently dropping every native play.
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_started",
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
val source: String? = null,
|
||||
@@ -28,21 +36,21 @@ data class PlayStartedResponse(
|
||||
|
||||
@Serializable
|
||||
data class PlayEndedRequest(
|
||||
val type: String = "play_ended",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_ended",
|
||||
@SerialName("play_event_id") val playEventId: String,
|
||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaySkippedRequest(
|
||||
val type: String = "play_skipped",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_skipped",
|
||||
@SerialName("play_event_id") val playEventId: String,
|
||||
@SerialName("position_ms") val positionMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlayOfflineRequest(
|
||||
val type: String = "play_offline",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_offline",
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
val at: String,
|
||||
|
||||
@@ -10,13 +10,17 @@ import kotlinx.serialization.Serializable
|
||||
* `internal/api/types.go HomeIndexPayload`).
|
||||
*
|
||||
* Section name implies entity type; no per-entry type tag is needed:
|
||||
* - recentlyAddedAlbums → album
|
||||
* - rediscoverAlbums → album
|
||||
* - rediscoverArtists → artist
|
||||
* - mostPlayedTracks → track
|
||||
* - lastPlayedArtists → artist
|
||||
* - recentlyAddedAlbums → album
|
||||
* - rediscoverAlbums → album
|
||||
* - rediscoverArtists → artist
|
||||
* - mostPlayedTracks → track
|
||||
* - lastPlayedArtists → artist
|
||||
* - youMightLikeAlbums → album
|
||||
* - youMightLikeArtists → artist
|
||||
*
|
||||
* All slices default to empty so missing keys don't throw on decode.
|
||||
* All slices default to empty so missing keys don't throw on decode —
|
||||
* which also keeps the client safe against older servers that don't
|
||||
* emit the you-might-like sections yet.
|
||||
*/
|
||||
@Serializable
|
||||
data class HomeIndexWire(
|
||||
@@ -25,4 +29,6 @@ data class HomeIndexWire(
|
||||
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
||||
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
||||
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
|
||||
@SerialName("you_might_like_albums") val youMightLikeAlbums: List<String> = emptyList(),
|
||||
@SerialName("you_might_like_artists") val youMightLikeArtists: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
-25
@@ -10,14 +10,19 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
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.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -39,9 +44,15 @@ import timber.log.Timber
|
||||
* wrapped ExoPlayer stays paused at the position it had when the
|
||||
* route was selected.
|
||||
*
|
||||
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
|
||||
* factory wraps that callback into a SharedFlow consumed by the
|
||||
* NowPlaying surface as a snackbar.
|
||||
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
|
||||
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
|
||||
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
|
||||
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
|
||||
* WiFi power-save can stall a single command's socket I/O for a second or
|
||||
* two while the renderer is perfectly reachable, so commands retry on
|
||||
* transient IO failure and otherwise defer to the poll loop. The factory
|
||||
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
|
||||
* surface as a snackbar.
|
||||
*
|
||||
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||
@@ -54,6 +65,8 @@ class MinstrelForwardingPlayer(
|
||||
private val delegate: Player,
|
||||
private val holder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val castNetworkLock: CastNetworkLock,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
private val onDrop: (routeName: String) -> Unit,
|
||||
) : ForwardingPlayer(delegate) {
|
||||
|
||||
@@ -107,6 +120,20 @@ class MinstrelForwardingPlayer(
|
||||
scope.launch {
|
||||
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
|
||||
// addObserver requires it. The observer just trySend's to the channel.
|
||||
handler.post {
|
||||
@@ -172,13 +199,14 @@ class MinstrelForwardingPlayer(
|
||||
if (active == null) {
|
||||
super.play()
|
||||
} else {
|
||||
remoteState.setPlayIntent(true)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.play() }
|
||||
runCatching { retryTransport { active.avTransport.play() } }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPlaying()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,13 +221,14 @@ class MinstrelForwardingPlayer(
|
||||
if (active == null) {
|
||||
super.pause()
|
||||
} else {
|
||||
remoteState.setPlayIntent(false)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.pause() }
|
||||
runCatching { retryTransport { active.avTransport.pause() } }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPaused()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,8 +251,8 @@ class MinstrelForwardingPlayer(
|
||||
trackNumber = remoteState.trackNumber,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.seek(positionMs) }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,11 +282,13 @@ class MinstrelForwardingPlayer(
|
||||
)
|
||||
scope.launch {
|
||||
runCatching {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
retryTransport {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
}
|
||||
}
|
||||
}.onFailure { handleSoapFailure(active, it) }
|
||||
}.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,8 +311,8 @@ class MinstrelForwardingPlayer(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.next() }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.next() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,8 +334,8 @@ class MinstrelForwardingPlayer(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.previous() }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.previous() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,11 +416,34 @@ class MinstrelForwardingPlayer(
|
||||
super.release()
|
||||
}
|
||||
|
||||
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
|
||||
pollJob?.cancel()
|
||||
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
|
||||
remoteState.applyError(t)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
/**
|
||||
* Run a transport SOAP block, retrying transport-level (IO) failures a
|
||||
* few times with backoff. A locked phone's WiFi power-save can stall the
|
||||
* first socket I/O for a second or two; a retry lets the command land once
|
||||
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
|
||||
* renderer answered and rejected the action) is NOT retried -- the device
|
||||
* is alive and retrying won't change its verdict.
|
||||
*/
|
||||
private suspend fun <T> retryTransport(block: suspend () -> T): T =
|
||||
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
|
||||
|
||||
/**
|
||||
* A transport SOAP command failed even after retries. Deliberately does
|
||||
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
|
||||
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
|
||||
* WiFi power-save can fail one command while the renderer is perfectly
|
||||
* reachable; dropping on a single command falsely kicked playback back to
|
||||
* the phone. We log, leave the poll loop running, and nudge an immediate
|
||||
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
|
||||
* truly gone, the poll loop trips the drop on its own.
|
||||
*/
|
||||
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
|
||||
if (t is SoapFaultException) {
|
||||
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
|
||||
} else {
|
||||
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
|
||||
}
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
|
||||
private fun onActiveChanged(active: ActiveUpnp?) {
|
||||
@@ -402,6 +456,11 @@ class MinstrelForwardingPlayer(
|
||||
lastNotifiedTrackIdx = -1
|
||||
if (active != null) {
|
||||
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
|
||||
// simultaneously with the remote renderer. handler.post targets the
|
||||
// application looper, so this runs on the same thread that processes
|
||||
@@ -411,20 +470,26 @@ class MinstrelForwardingPlayer(
|
||||
handler.post { delegate.pause() }
|
||||
pollJob = scope.launch { pollLoop(active) }
|
||||
} else {
|
||||
castNetworkLock.release()
|
||||
remoteState.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||
var networkDropSuppressed = false
|
||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||
val outcome = runCatching { pollOnce(active) }
|
||||
if (outcome.isSuccess) {
|
||||
remoteState.recordPollSuccess()
|
||||
networkDropSuppressed = false
|
||||
} else if (remoteState.recordPollFailure()) {
|
||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
return
|
||||
if (networkStatus.state.value == ServerHealth.Healthy) {
|
||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
return
|
||||
}
|
||||
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
|
||||
}
|
||||
// Race the normal cadence against any external wake (activity
|
||||
// resume). Whichever wins continues to the next pollOnce.
|
||||
@@ -435,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
|
||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||
@@ -513,5 +602,39 @@ class MinstrelForwardingPlayer(
|
||||
// when Sonos's reported Track matches the wrapped player; this only
|
||||
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
||||
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
||||
// Transport commands retry transient IO failures so a single WiFi
|
||||
// power-save stall (locked phone) doesn't abandon the command. 3
|
||||
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
|
||||
// worst case at ~7s; a still-failing command then defers to the poll.
|
||||
const val TRANSPORT_RETRY_ATTEMPTS = 3
|
||||
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry [block] on transient transport-level ([IOException]) failures, with
|
||||
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
|
||||
* an [IOException] -- notably [SoapFaultException], where the renderer
|
||||
* answered and rejected the action -- propagates immediately: the device is
|
||||
* alive, so retrying won't change its verdict. Extracted from
|
||||
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
|
||||
* abandon a single transport command (the bug that falsely reverted Sonos
|
||||
* playback to local audio).
|
||||
*/
|
||||
internal suspend fun <T> retryTransientIo(
|
||||
attempts: Int,
|
||||
backoffMs: Long,
|
||||
block: suspend () -> T,
|
||||
): T {
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (io: IOException) {
|
||||
attempt += 1
|
||||
if (attempt >= attempts) throw io
|
||||
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
|
||||
delay(backoffMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-6
@@ -3,14 +3,14 @@ package com.fabledsword.minstrel.player
|
||||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
|
||||
/**
|
||||
* DataSource wrapper that fails the network read immediately when
|
||||
* [ServerHealthController] reports a non-Healthy state. CacheDataSource only
|
||||
* [NetworkStatusController] reports a gating state. CacheDataSource only
|
||||
* calls this upstream factory on cache misses, so playback of cached audio is
|
||||
* unaffected -- only "tap a non-cached track while offline" hits this branch
|
||||
* and gets a fast, meaningful error instead of a multi-second network timeout
|
||||
@@ -22,10 +22,23 @@ import java.io.InterruptedIOException
|
||||
*/
|
||||
class OfflineGatedDataSource(
|
||||
private val delegate: DataSource,
|
||||
private val health: ServerHealthController,
|
||||
private val health: NetworkStatusController,
|
||||
) : DataSource {
|
||||
|
||||
override fun open(dataSpec: DataSpec): Long {
|
||||
gateOnHealth()
|
||||
return try {
|
||||
val opened = delegate.open(dataSpec)
|
||||
health.reportSuccess() // bytes flowing from the server == reachable
|
||||
opened
|
||||
} catch (e: IOException) {
|
||||
health.reportFailure() // real network read failed → arbitrate via /healthz
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Fast-fail before touching the network when the server can't be reached. */
|
||||
private fun gateOnHealth() {
|
||||
when (health.state.value) {
|
||||
ServerHealth.Offline -> throw OfflineException(
|
||||
"Track not in the on-device cache and the device is offline.",
|
||||
@@ -33,9 +46,9 @@ class OfflineGatedDataSource(
|
||||
ServerHealth.ServerDown -> throw OfflineException(
|
||||
"Track not in the on-device cache and the Minstrel server is unreachable.",
|
||||
)
|
||||
ServerHealth.Healthy -> Unit
|
||||
// Unstable is non-gating: still try the network. Healthy too.
|
||||
ServerHealth.Unstable, ServerHealth.Healthy -> Unit
|
||||
}
|
||||
return delegate.open(dataSpec)
|
||||
}
|
||||
|
||||
override fun close() = delegate.close()
|
||||
@@ -49,7 +62,7 @@ class OfflineGatedDataSource(
|
||||
|
||||
class OfflineGatedDataSourceFactory(
|
||||
private val upstream: DataSource.Factory,
|
||||
private val health: ServerHealthController,
|
||||
private val health: NetworkStatusController,
|
||||
) : DataSource.Factory {
|
||||
override fun createDataSource(): DataSource =
|
||||
OfflineGatedDataSource(upstream.createDataSource(), health)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.cache.mutations.PlayEndedPayload
|
||||
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||
@@ -64,11 +65,24 @@ class PlayEventsReporter @Inject constructor(
|
||||
private var curLastPositionMs: Long = 0
|
||||
private var curDurationMs: Long = 0
|
||||
|
||||
// Whether the currently-tracked play is actively playing (vs paused).
|
||||
// onStop reads this: a backgrounded play that's still playing is left
|
||||
// to the live path (the foreground service keeps us alive), while a
|
||||
// paused one is durably closed so a kill can't orphan its open row.
|
||||
private var curPlaying: Boolean = false
|
||||
|
||||
// Server play_event_id when the live play_started succeeded;
|
||||
// null if start failed/offline — then the close is captured into
|
||||
// the offline mutation queue instead of a live ended/skipped.
|
||||
private var openPlayEventId: String? = null
|
||||
|
||||
// True from beginTrack until startLive's play_started resolves. When a
|
||||
// close races an in-flight start (id still null), the server row is
|
||||
// being created but we've lost its id — we must NOT also enqueue an
|
||||
// offline play (that would duplicate); the server auto-closes the
|
||||
// orphan on the next play_started instead.
|
||||
private var startInFlight: Boolean = false
|
||||
|
||||
private var prevTrackId: String? = null
|
||||
|
||||
init {
|
||||
@@ -95,7 +109,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
) {
|
||||
// Track-change → close the prior tracked play.
|
||||
if (trackId != prevTrackId && curTrackId != null) {
|
||||
closeCurrent(viaOffline = false)
|
||||
closeCurrent()
|
||||
}
|
||||
|
||||
// Entered playing for a new track → begin tracking it.
|
||||
@@ -110,6 +124,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
if (curTrackId != null && trackId == curTrackId) {
|
||||
curLastPositionMs = positionMs
|
||||
if (durationMs > 0) curDurationMs = durationMs
|
||||
curPlaying = playing
|
||||
}
|
||||
|
||||
prevTrackId = trackId
|
||||
@@ -121,12 +136,18 @@ class PlayEventsReporter @Inject constructor(
|
||||
curSource = source
|
||||
curLastPositionMs = 0
|
||||
curDurationMs = durationMs
|
||||
curPlaying = true
|
||||
openPlayEventId = null
|
||||
startInFlight = true
|
||||
startLive(trackId = trackId, source = source)
|
||||
}
|
||||
|
||||
private fun startLive(trackId: String, source: String?) {
|
||||
val cid = resolveClientId() ?: return
|
||||
val cid = resolveClientId()
|
||||
if (cid == null) {
|
||||
startInFlight = false
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
val response = api.playStarted(
|
||||
@@ -144,56 +165,72 @@ class PlayEventsReporter @Inject constructor(
|
||||
) {
|
||||
// Offline / flaky — openPlayEventId stays null; close
|
||||
// path will enqueue the completed play for replay.
|
||||
} finally {
|
||||
startInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeCurrent(viaOffline: Boolean) {
|
||||
/**
|
||||
* Closes the tracked play. Routes by whether the server already has an
|
||||
* open row, NOT by caller context — this is what keeps background /
|
||||
* track-change / failure paths from each minting a second row:
|
||||
* - open server row (id) → close it by id; on failure enqueue a durable
|
||||
* close-by-id (never an offline play).
|
||||
* - start in flight (no id yet) → leave it; the server auto-closes the
|
||||
* orphan on the next play_started. Enqueuing offline here would
|
||||
* duplicate the in-flight row.
|
||||
* - no server row at all → record the whole play offline.
|
||||
*
|
||||
* Always uses the actual position played; the server's skip rule
|
||||
* (50%/30s defaults) classifies was_skipped from it.
|
||||
*/
|
||||
private fun closeCurrent() {
|
||||
val trackId = curTrackId
|
||||
val startedAt = curStartedAt
|
||||
if (trackId == null || startedAt == null) {
|
||||
resetCurrent()
|
||||
return
|
||||
}
|
||||
// Always send the actual position played to; the server's
|
||||
// skip rule (50%/30s defaults, configurable) classifies
|
||||
// was_skipped from this. If the track ran to completion the
|
||||
// last observed position is ~= duration, which gives ratio ~1
|
||||
// and the server records a non-skip. The COMPLETION_TOLERANCE
|
||||
// rounding the prior shape did is no longer needed — the
|
||||
// server's threshold is the source of truth.
|
||||
val durationMs = curLastPositionMs
|
||||
val source = curSource
|
||||
val id = openPlayEventId
|
||||
|
||||
if (!viaOffline && id != null) {
|
||||
scope.launch {
|
||||
try {
|
||||
api.playEnded(
|
||||
PlayEndedRequest(
|
||||
playEventId = id,
|
||||
durationPlayedMs = durationMs,
|
||||
),
|
||||
)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
val inFlight = startInFlight
|
||||
when {
|
||||
id != null -> closeById(id, durationMs)
|
||||
inFlight -> Unit
|
||||
else -> enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
}
|
||||
resetCurrent()
|
||||
}
|
||||
|
||||
private fun closeById(id: String, durationMs: Long) {
|
||||
scope.launch {
|
||||
try {
|
||||
api.playEnded(
|
||||
PlayEndedRequest(playEventId = id, durationPlayedMs = durationMs),
|
||||
)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Durable close-by-id (idempotent server-side) — survives a
|
||||
// process kill without orphaning the open row or duplicating.
|
||||
mutationQueue.enqueuePlayEnded(
|
||||
PlayEndedPayload(playEventId = id, durationPlayedMs = durationMs),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetCurrent() {
|
||||
curTrackId = null
|
||||
curStartedAt = null
|
||||
curSource = null
|
||||
curLastPositionMs = 0
|
||||
curDurationMs = 0
|
||||
curPlaying = false
|
||||
openPlayEventId = null
|
||||
startInFlight = false
|
||||
}
|
||||
|
||||
private fun enqueueOffline(
|
||||
@@ -232,8 +269,17 @@ class PlayEventsReporter @Inject constructor(
|
||||
// ── Lifecycle: durable-close on app background / detach ────────
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (curTrackId != null) {
|
||||
closeCurrent(viaOffline = true)
|
||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||
// alone: the foreground media service keeps the process — and this
|
||||
// app-scope uiState collector — alive, so the live state machine
|
||||
// closes the play accurately on the next real track change. Closing
|
||||
// here (the old always-offline behavior) raced the collector's
|
||||
// re-begin and minted a duplicate, skip-misclassified row on every
|
||||
// screen-lock. Only durably close a PAUSED play, where no further
|
||||
// progress will happen and a process kill would otherwise orphan the
|
||||
// open server row. See docs/superpowers/2026-06-11 contract audit.
|
||||
if (curTrackId != null && !curPlaying) {
|
||||
closeCurrent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
@@ -40,6 +41,7 @@ private const val DEBOUNCE_MS = 2_000L
|
||||
class PlaybackErrorReporter @Inject constructor(
|
||||
private val playerController: PlayerController,
|
||||
private val repository: PlaybackErrorRepository,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
) {
|
||||
private val outChannel = Channel<String>(Channel.BUFFERED)
|
||||
@@ -52,6 +54,10 @@ class PlaybackErrorReporter @Inject constructor(
|
||||
val buffer = mutableListOf<String>()
|
||||
var debounceJob: kotlinx.coroutines.Job? = null
|
||||
playerController.playbackErrorEvents.collect { event ->
|
||||
// A track failing to play is ambiguous (dead server vs. one bad
|
||||
// file) — let the controller arbitrate via /healthz. No-op when
|
||||
// already Offline; cheap otherwise.
|
||||
networkStatus.reportFailure()
|
||||
// Fire-and-forget the server report — repository handles
|
||||
// success/queue branching so callers don't see throws.
|
||||
scope.launch { repository.report(event) }
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -63,6 +65,7 @@ class PlayerController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val radio: RadioController,
|
||||
private val playlists: PlaylistsRepository,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
@@ -111,6 +114,13 @@ class PlayerController @Inject constructor(
|
||||
*/
|
||||
private var lastEvaluatedItemIndex: Int = -1
|
||||
|
||||
/**
|
||||
* #968: self-heal budget — re-pulls of a stale refreshable queue since
|
||||
* the last successful play. Capped so a still-broken refresh can't loop;
|
||||
* reset when any track loads with real audio.
|
||||
*/
|
||||
private var selfHealAttempts = 0
|
||||
|
||||
/**
|
||||
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||
* the player's own getMediaItem(index) returns Media3 types; we keep
|
||||
@@ -192,6 +202,20 @@ class PlayerController @Inject constructor(
|
||||
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].
|
||||
* [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
|
||||
val duration = controller.duration
|
||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||
if (!isZeroDuration) return
|
||||
if (!isZeroDuration) {
|
||||
// A track loaded with real audio — clear the self-heal budget so
|
||||
// a later stale queue can recover again.
|
||||
selfHealAttempts = 0
|
||||
return
|
||||
}
|
||||
playbackErrorEventsChannel.trySend(
|
||||
PlaybackErrorEvent(
|
||||
trackId = current.id,
|
||||
@@ -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 ──────────
|
||||
|
||||
private suspend fun connectAndObserve() {
|
||||
@@ -403,6 +484,17 @@ class PlayerController @Inject constructor(
|
||||
detail = error.message,
|
||||
),
|
||||
)
|
||||
// A failed load leaves the player IDLE; advance past the bad
|
||||
// track and re-prepare so one unplayable item doesn't strand
|
||||
// playback (mirrors the zero_duration skip). At the end of a
|
||||
// fully-unplayable queue, try to self-heal a stale refreshable
|
||||
// source before stopping.
|
||||
if (controller.hasNextMediaItem()) {
|
||||
controller.seekToNextMediaItem()
|
||||
controller.prepare()
|
||||
} else {
|
||||
selfHealOrStop(controller)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
@@ -749,6 +841,10 @@ class PlayerController @Inject constructor(
|
||||
// they don't need this poll.
|
||||
private const val POSITION_POLL_INTERVAL_MS = 500L
|
||||
|
||||
// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
|
||||
// the next successful play) so a still-broken refresh can't loop.
|
||||
private const val MAX_SELF_HEAL_ATTEMPTS = 1
|
||||
|
||||
/**
|
||||
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||
|
||||
@@ -55,7 +55,7 @@ class PlayerFactory @Inject constructor(
|
||||
private val cacheConfig: CacheConfig,
|
||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.ServerHealthController,
|
||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) {
|
||||
private val cacheDir: File = File(context.cacheDir, "audio_cache").apply { mkdirs() }
|
||||
|
||||
@@ -81,6 +81,8 @@ class PlayerFactory @Inject constructor(
|
||||
delegate = exo,
|
||||
holder = activeUpnpHolder,
|
||||
remoteState = remoteState,
|
||||
castNetworkLock = CastNetworkLock(context),
|
||||
networkStatus = serverHealth,
|
||||
onDrop = { name -> emitDrop(name) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import javax.inject.Singleton
|
||||
* Updates flow in from:
|
||||
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
||||
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
||||
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
|
||||
* - Poll failures -> recordPollFailure()
|
||||
*
|
||||
* The poll-failure counter implements the rolling-3 drop heuristic: 3
|
||||
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
|
||||
* consecutive poll failures = remote considered dropped (returns true
|
||||
* from recordPollFailure for the caller to surface). Success resets it.
|
||||
* from recordPollFailure for the caller to surface). Any success resets it.
|
||||
* Failed transport commands no longer drop the route -- they retry and
|
||||
* otherwise defer to this counter (see MinstrelForwardingPlayer).
|
||||
*/
|
||||
@Singleton
|
||||
class RemotePlayerState @Inject constructor() {
|
||||
@@ -27,6 +29,13 @@ class RemotePlayerState @Inject constructor() {
|
||||
@Volatile var lastError: Throwable? = null; private set
|
||||
@Volatile var trackNumber: Int = 0; private set
|
||||
|
||||
// The operator's last explicit transport intent (play=true, pause=false),
|
||||
// distinct from the observed remote isPlaying. handleRemoteDrop resumes
|
||||
// local playback based on THIS, not isPlaying -- so a play() that fails
|
||||
// its SOAP (stale/dead renderer) still resumes on the phone instead of
|
||||
// swallowing the tap. Deliberately NOT cleared by applyError.
|
||||
@Volatile var lastPlayIntent: Boolean = false; private set
|
||||
|
||||
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
||||
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
||||
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
||||
@@ -63,6 +72,8 @@ class RemotePlayerState @Inject constructor() {
|
||||
positionMs = 0L
|
||||
}
|
||||
|
||||
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
|
||||
|
||||
fun applyError(t: Throwable) {
|
||||
isPlaying = false
|
||||
lastError = t
|
||||
@@ -80,6 +91,7 @@ class RemotePlayerState @Inject constructor() {
|
||||
positionMs = 0L
|
||||
durationMs = 0L
|
||||
isPlaying = false
|
||||
lastPlayIntent = false
|
||||
currentTrackUri = ""
|
||||
lastError = null
|
||||
consecutivePollFailures = 0
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
/** Action the idle-revert collector should take on a player-state change. */
|
||||
internal enum class IdleRevertAction { ARM, CANCEL, IGNORE }
|
||||
|
||||
/**
|
||||
* Pure decision for the UPnP idle-revert timer. [armed] is whether the
|
||||
* idle timer Job is currently running. We ARM only on the transition INTO
|
||||
* "engaged + not playing" (so repeated paused emissions don't reset the
|
||||
* countdown), CANCEL whenever playback resumes or UPnP disengages, and
|
||||
* otherwise IGNORE.
|
||||
*/
|
||||
internal fun idleRevertAction(
|
||||
upnpEngaged: Boolean,
|
||||
isPlaying: Boolean,
|
||||
armed: Boolean,
|
||||
): IdleRevertAction = when {
|
||||
upnpEngaged && !isPlaying -> if (armed) IdleRevertAction.IGNORE else IdleRevertAction.ARM
|
||||
armed -> IdleRevertAction.CANCEL
|
||||
else -> IdleRevertAction.IGNORE
|
||||
}
|
||||
+202
-5
@@ -15,10 +15,12 @@ import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||
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.bareUdn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
@@ -147,6 +150,12 @@ class OutputPickerController @Inject constructor(
|
||||
// can run the longest-common-prefix / common-suffix diff.
|
||||
private var lastSyncedQueueIds: List<String>? = null
|
||||
|
||||
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
|
||||
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
|
||||
// non-playing. Cancelled on resume, route change, or explicit disconnect.
|
||||
// See idleRevertAction for the arm/cancel decision.
|
||||
private var idleRevertJob: Job? = null
|
||||
|
||||
init {
|
||||
// Two-arg addCallback registers with no discovery flag —
|
||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||
@@ -155,7 +164,8 @@ class OutputPickerController @Inject constructor(
|
||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||
mediaRouter.addCallback(selector, callback)
|
||||
// When MinstrelForwardingPlayer reports the active UPnP route has
|
||||
// dropped (3+ consecutive poll failures or a transport SOAP exception),
|
||||
// dropped (the poll loop's consecutive-failure threshold tripping --
|
||||
// the sole drop arbiter; a failed transport command no longer drops),
|
||||
// clear the UPnP selection state and fall back to local ExoPlayer at
|
||||
// the last-known remote position. This mirrors selectSystem's disconnect
|
||||
// path but skips the Stop SOAP since the device is already unreachable.
|
||||
@@ -163,6 +173,79 @@ class OutputPickerController @Inject constructor(
|
||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||
}
|
||||
scope.launch { observeQueueChangesForSonosResync() }
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm/cancel the idle-revert timer as playback state changes while a
|
||||
* UPnP route is engaged. "Engaged" = active OR target set, so the brief
|
||||
* selectUpnp/resync window (active momentarily null, target set) doesn't
|
||||
* spuriously fire. The arm/cancel choice is the pure idleRevertAction.
|
||||
*/
|
||||
private suspend fun observeIdleRevertWhileUpnp() {
|
||||
playerController.uiState.collect { state ->
|
||||
val engaged = activeUpnpHolder.active.value != null ||
|
||||
activeUpnpHolder.target.value != null
|
||||
val armed = idleRevertJob?.isActive == true
|
||||
when (idleRevertAction(engaged, state.isPlaying, armed)) {
|
||||
IdleRevertAction.ARM ->
|
||||
idleRevertJob = scope.launch {
|
||||
delay(IDLE_REVERT_MS)
|
||||
revertToPhoneOnIdle()
|
||||
}
|
||||
IdleRevertAction.CANCEL -> {
|
||||
idleRevertJob?.cancel()
|
||||
idleRevertJob = null
|
||||
}
|
||||
IdleRevertAction.IGNORE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer fired: the operator paused on a UPnP route and never resumed.
|
||||
* Stop the renderer (best-effort -- it's reachable, unlike a drop),
|
||||
* clear the UPnP overlay so routesState falls back to the phone, and
|
||||
* seek local ExoPlayer to the last remote position. No play(): the
|
||||
* operator was paused, so we revert routing silently. Guarded by the
|
||||
* selectUpnpMutex so it can't race selectUpnp/resync.
|
||||
*/
|
||||
private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
|
||||
if (remoteState.isPlaying) return@withLock // resumed at the boundary
|
||||
val active = activeUpnpHolder.active.value
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
runCatching { active?.avTransport?.stop() }
|
||||
.onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
idleRevertJob = null
|
||||
playerController.seekTo(capturedPositionMs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,16 +448,19 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the active UPnP route drops unexpectedly (poll-failure
|
||||
* threshold or SOAP exception). Captures the last remote position +
|
||||
* play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
||||
* consecutive-failure threshold tripping). Captures the last remote
|
||||
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||
* the same point. Skips the Stop SOAP (device already unreachable).
|
||||
* The snackbar is handled independently by the NowPlaying surface
|
||||
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
||||
*/
|
||||
private fun handleRemoteDrop() {
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
val wasPlayingRemote = remoteState.isPlaying
|
||||
// Resume on the operator's last play-intent, not the observed remote
|
||||
// state: after a pause, isPlaying is false, but a failed play() that
|
||||
// triggered this drop means the operator DID ask to play -- honor it.
|
||||
val wasPlayingRemote = remoteState.lastPlayIntent
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
@@ -482,6 +568,13 @@ class OutputPickerController @Inject constructor(
|
||||
// local audio while we queue up Sonos.
|
||||
playerController.pause()
|
||||
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
|
||||
// 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.
|
||||
@@ -497,6 +590,9 @@ class OutputPickerController @Inject constructor(
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
// Selecting a route loads the queue and Plays on the renderer, so
|
||||
// the standing intent is "play" -- a later drop should resume local.
|
||||
remoteState.setPlayIntent(true)
|
||||
}.onFailure { e ->
|
||||
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||
activeUpnpHolder.set(null)
|
||||
@@ -505,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(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
@@ -640,5 +832,10 @@ class OutputPickerController @Inject constructor(
|
||||
private companion object {
|
||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||
const val EXTEND_THROTTLE_MS = 50L
|
||||
|
||||
// 5 minutes of continuous non-playing on a UPnP route before we
|
||||
// revert to the phone speaker, so a stale Sonos selection can't make
|
||||
// a later "tap play" do nothing.
|
||||
const val IDLE_REVERT_MS = 5 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
+55
-2
@@ -8,6 +8,7 @@ import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -17,6 +18,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -45,9 +48,24 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
) {
|
||||
private val ssdp = SsdpDiscovery(context)
|
||||
|
||||
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
|
||||
// this client. A dead renderer should fail fast so OutputPickerController's
|
||||
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
|
||||
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
|
||||
// dispatcher stay shared with the app client.
|
||||
private val controlHttp: OkHttpClient = okHttp.newBuilder()
|
||||
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||
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())
|
||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||
|
||||
@@ -68,7 +86,31 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
* that just opened).
|
||||
*/
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,7 +133,7 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
fun transportFor(routeId: String): AVTransportClient? {
|
||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||
return AVTransportClient(
|
||||
loggingSoapClient(okHttp, route.name),
|
||||
loggingSoapClient(controlHttp, route.name),
|
||||
route.avTransportControlUrl,
|
||||
)
|
||||
}
|
||||
@@ -105,6 +147,7 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
}
|
||||
|
||||
private fun upsertRoute(route: UpnpRoute) {
|
||||
lastSeenMs[route.id] = System.currentTimeMillis()
|
||||
val current = routesInternal.value
|
||||
val idx = current.indexOfFirst { it.id == route.id }
|
||||
routesInternal.value = if (idx < 0) {
|
||||
@@ -225,6 +268,16 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
// devices append. Anchored to end-of-string so it never eats
|
||||
// a legitimate parenthetical inside a name.
|
||||
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
||||
|
||||
// Aggressive connect timeout for transport SOAP so a dead renderer
|
||||
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
||||
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
-2
@@ -7,6 +7,7 @@ import retrofit2.HttpException
|
||||
import java.net.HttpURLConnection
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
@@ -18,6 +19,7 @@ import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
|
||||
import com.fabledsword.minstrel.models.wire.PlaylistWire
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
@@ -51,9 +53,16 @@ class PlaylistsRepository @Inject constructor(
|
||||
|
||||
// ── Reads (Flow, cache-first; the cache is the source of truth) ──
|
||||
|
||||
/** Owned + public; UI splits by isSystem / userId comparison. */
|
||||
/**
|
||||
* Owned + public; UI splits by isSystem / userId comparison. Joined with
|
||||
* per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
|
||||
* greying without each consumer re-querying the cache index.
|
||||
*/
|
||||
fun observeAll(): Flow<List<PlaylistRef>> =
|
||||
playlistDao.observeAll().map { rows -> rows.map { it.toDomain() } }
|
||||
combine(
|
||||
playlistDao.observeAll(),
|
||||
playlistDao.observeCachedCounts(),
|
||||
) { rows, counts -> mergePlaylistsWithCache(rows, counts) }
|
||||
|
||||
/** User-owned playlists only (systemVariant IS NULL). */
|
||||
fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
|
||||
@@ -233,6 +242,23 @@ fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
|
||||
|
||||
// ── Mappers (internal — keep Room + wire types out of the UI layer) ──
|
||||
|
||||
/**
|
||||
* Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached]
|
||||
* from the cache-index counts. A playlist is fully cached when it has tracks
|
||||
* and every member track is resident (`cachedCount >= trackCount`). Pulled out
|
||||
* of the Flow so the predicate is unit-testable without a Room database.
|
||||
*/
|
||||
internal fun mergePlaylistsWithCache(
|
||||
rows: List<CachedPlaylistEntity>,
|
||||
counts: List<PlaylistCachedCount>,
|
||||
): List<PlaylistRef> {
|
||||
val cachedById = counts.associate { it.playlistId to it.cachedCount }
|
||||
return rows.map { row ->
|
||||
val cached = cachedById[row.id] ?: 0
|
||||
row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount)
|
||||
}
|
||||
}
|
||||
|
||||
private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
|
||||
PlaylistRef(
|
||||
id = id,
|
||||
|
||||
+68
@@ -28,6 +28,10 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -139,6 +143,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||
|
||||
/**
|
||||
* #980: a system-playlist rebuild landed (SSE) while this screen is open —
|
||||
* its list is now stale and the uuid has rotated. Drives a "this mix was
|
||||
* refreshed → Refresh" snackbar rather than yanking the user; Refresh
|
||||
* re-resolves the variant via [reloadRebuilt].
|
||||
*/
|
||||
private val staleInternal = MutableStateFlow(false)
|
||||
val stale: StateFlow<Boolean> = staleInternal.asStateFlow()
|
||||
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
@@ -163,6 +176,12 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
* ignored — the playlists-list screen handles those.
|
||||
*/
|
||||
private fun handlePlaylistEvent(event: LiveEvent) {
|
||||
// #980: a system rebuild carries no playlist_id (it rebuilds all of the
|
||||
// user's system mixes at once) — handle it before the per-id filter.
|
||||
if (event.kind == "playlist.system_rebuilt") {
|
||||
markStaleIfSystem()
|
||||
return
|
||||
}
|
||||
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
||||
if (eventPlaylistId != playlistId) return
|
||||
when (event.kind) {
|
||||
@@ -171,6 +190,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun markStaleIfSystem() {
|
||||
val playlist = (internal.value as? PlaylistDetailUiState.Success)
|
||||
?.detail?.playlist ?: return
|
||||
if (playlist.systemVariant?.takeIf { playlist.refreshable } != null) {
|
||||
staleInternal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleLikeTrack(trackId: String) {
|
||||
val desired = trackId !in likedTrackIds.value
|
||||
viewModelScope.launch {
|
||||
@@ -220,6 +247,30 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #980: user tapped Refresh on the stale-view snackbar. The rebuild
|
||||
* already ran server-side (uuid rotated) — re-resolve the variant to the
|
||||
* fresh playlist id and hand it to [regenerated] for navigate-replace.
|
||||
* Unlike [regenerate] this does NOT trigger another rebuild.
|
||||
*/
|
||||
fun reloadRebuilt() {
|
||||
val playlist = (internal.value as? PlaylistDetailUiState.Success)
|
||||
?.detail?.playlist ?: return
|
||||
val variant = playlist.systemVariant?.takeIf { playlist.refreshable } ?: return
|
||||
staleInternal.value = false
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.systemShuffle(variant).playlist.id }
|
||||
.onSuccess { newId ->
|
||||
if (newId.isNotEmpty()) regeneratedChannel.trySend(newId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** #980: user dismissed the stale-view snackbar without refreshing. */
|
||||
fun dismissStale() {
|
||||
staleInternal.value = false
|
||||
}
|
||||
|
||||
/** Play the available tracks starting at [startTrackId] (or first available). */
|
||||
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
||||
val refs = tracks.toPlayableTrackRefs()
|
||||
@@ -261,8 +312,25 @@ fun PlaylistDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
// #980: a rebuild landed while this system-playlist screen is open. Offer a
|
||||
// non-intrusive "refreshed → Refresh" snackbar; Refresh re-resolves the
|
||||
// rotated uuid (via the regenerated navigate-replace flow).
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val stale by viewModel.stale.collectAsState()
|
||||
LaunchedEffect(stale) {
|
||||
if (stale) {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = "This mix was refreshed",
|
||||
actionLabel = "Refresh",
|
||||
duration = SnackbarDuration.Indefinite,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) viewModel.reloadRebuilt()
|
||||
else viewModel.dismissStale()
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(currentTitle(state)) },
|
||||
|
||||
+14
-10
@@ -27,7 +27,8 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||
@@ -66,7 +67,7 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
private val repository: PlaylistsRepository,
|
||||
private val player: PlayerController,
|
||||
private val eventsStream: EventsStream,
|
||||
connectivity: ConnectivityObserver,
|
||||
networkStatus: NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
||||
@@ -74,9 +75,9 @@ class PlaylistsListViewModel @Inject constructor(
|
||||
/** Transient snackbar messages from playlist tile play taps. */
|
||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||
|
||||
/** True when the device has no usable internet -- gates refreshable system tiles. */
|
||||
val offline: StateFlow<Boolean> = connectivity.online
|
||||
.map { !it }
|
||||
/** Cache-only: no link OR server unreachable (Unstable stays calm). Greys tiles. */
|
||||
val offline: StateFlow<Boolean> = networkStatus.state
|
||||
.map { it == ServerHealth.Offline || it == ServerHealth.ServerDown }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||
@@ -191,14 +192,15 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("System playlists")
|
||||
}
|
||||
items(items = systemPlaylists, key = { it.id }) { playlist ->
|
||||
// Greyed offline when it needs the live server or isn't fully
|
||||
// cached — dimmed but still tappable into the detail.
|
||||
val greyed = offline && playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
// Refreshable system tiles need server endpoints (systemShuffle);
|
||||
// disable when offline. Empty mixes show a snackbar after tap.
|
||||
playEnabled = playlist.trackCount > 0 &&
|
||||
!(offline && playlist.refreshable),
|
||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -207,11 +209,13 @@ private fun PlaylistsGrid(
|
||||
SectionHeader("Your playlists")
|
||||
}
|
||||
items(items = userPlaylists, key = { it.id }) { playlist ->
|
||||
val greyed = offline && playlist.unavailableOffline
|
||||
PlaylistCard(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistClick(playlist.id) },
|
||||
onPlay = { onPlay(playlist) },
|
||||
playEnabled = playlist.trackCount > 0,
|
||||
playEnabled = playlist.trackCount > 0 && !greyed,
|
||||
greyed = greyed,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-1
@@ -15,6 +15,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -40,6 +41,10 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
||||
* [playEnabled] disables the overlay (50% alpha + ignore taps) — Home
|
||||
* uses it for system playlists in offline mode (their server-side
|
||||
* shuffle endpoint is unreachable) and for empty playlists.
|
||||
*
|
||||
* [greyed] dims the whole tile (it can't be reliably played offline) while
|
||||
* keeping it tappable — the detail screen is the escape hatch for shuffling
|
||||
* whatever subset is cached. Greyed always disables the play overlay too.
|
||||
*/
|
||||
@Composable
|
||||
fun PlaylistCard(
|
||||
@@ -48,11 +53,13 @@ fun PlaylistCard(
|
||||
modifier: Modifier = Modifier,
|
||||
onPlay: (suspend () -> Unit)? = null,
|
||||
playEnabled: Boolean = true,
|
||||
greyed: Boolean = false,
|
||||
) {
|
||||
val seedCache = LocalDetailSeedCache.current
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(176.dp)
|
||||
.alpha(if (greyed) GREYED_ALPHA else 1f) // dimmed, still clickable
|
||||
.clickable {
|
||||
seedCache.stashPlaylist(playlist)
|
||||
onClick()
|
||||
@@ -63,7 +70,7 @@ fun PlaylistCard(
|
||||
PlaylistCardCover(
|
||||
playlist = playlist,
|
||||
onPlay = onPlay,
|
||||
playEnabled = playEnabled,
|
||||
playEnabled = playEnabled && !greyed,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
@@ -148,6 +155,7 @@ private fun VariantPill(label: String, modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
private const val PILL_BG_ALPHA = 0.85f
|
||||
private const val GREYED_ALPHA = 0.45f
|
||||
|
||||
private fun subtitleFor(playlist: PlaylistRef): String = when {
|
||||
playlist.trackCount > 0 -> "${playlist.trackCount} tracks"
|
||||
|
||||
+47
-1
@@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.models.RequestStatus
|
||||
import com.fabledsword.minstrel.requests.data.CancelOutcome
|
||||
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -20,6 +22,10 @@ import javax.inject.Inject
|
||||
|
||||
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
||||
|
||||
// Cadence for in-flight request polling — matches the web client (#369):
|
||||
// fast enough to feel live, slow enough not to hammer the server.
|
||||
private const val POLL_INTERVAL_MS = 12_000L
|
||||
|
||||
@HiltViewModel
|
||||
class RequestsViewModel @Inject constructor(
|
||||
private val repository: RequestsRepository,
|
||||
@@ -31,11 +37,14 @@ class RequestsViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// SSE push: reconciler completions arrive here; reload silently so the
|
||||
// row updates in place rather than flashing the loading spinner.
|
||||
viewModelScope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||
.collect { refresh() }
|
||||
.collect { silentReload() }
|
||||
}
|
||||
viewModelScope.launch { pollWhileInFlight() }
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
@@ -54,6 +63,43 @@ class RequestsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-poll while any request is mid-ingest (status APPROVED), matching the
|
||||
* web client (#369). Reloads silently every [POLL_INTERVAL_MS] and pauses
|
||||
* when nothing is in-flight — complements the SSE push so progress stays
|
||||
* live even if the event stream drops.
|
||||
*/
|
||||
private suspend fun pollWhileInFlight() {
|
||||
while (true) {
|
||||
if (hasInFlightRequest()) {
|
||||
silentReload()
|
||||
}
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasInFlightRequest(): Boolean {
|
||||
val state = internal.value
|
||||
return state is UiState.Success &&
|
||||
state.data.any { it.status == RequestStatus.APPROVED }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the list in place without flipping to Loading — used by the poll
|
||||
* loop and the SSE collector so visible rows update without a spinner
|
||||
* flash. A transient failure keeps the current view; the next reload wins.
|
||||
*/
|
||||
private suspend fun silentReload() {
|
||||
try {
|
||||
val rows = repository.listMine()
|
||||
internal.value = if (rows.isEmpty()) UiState.Empty else UiState.Success(rows)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Keep the current view; the next poll / SSE / pull reconciles.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically removes [id] from the success list so the row
|
||||
* disappears immediately; on server-side success the row is
|
||||
|
||||
@@ -4,8 +4,8 @@ import com.fabledsword.minstrel.api.endpoints.SearchApi
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealthController
|
||||
import com.fabledsword.minstrel.library.data.toDomain
|
||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||
import retrofit2.Retrofit
|
||||
@@ -16,8 +16,8 @@ import javax.inject.Singleton
|
||||
private const val LOCAL_SEARCH_LIMIT = 20
|
||||
|
||||
/**
|
||||
* Cache-first when offline. When [ServerHealthController] is Healthy the
|
||||
* repository hits `/api/search` and returns the server's three-facet
|
||||
* Cache-first when offline. When [NetworkStatusController] reports Healthy or
|
||||
* Unstable the repository hits `/api/search` and returns the server's three-facet
|
||||
* paged response. When health is Offline or ServerDown it falls back to
|
||||
* Room LIKE queries against `cached_*` so the user can still find
|
||||
* something to play from what's already on the device; the outcome's
|
||||
@@ -27,7 +27,7 @@ private const val LOCAL_SEARCH_LIMIT = 20
|
||||
@Singleton
|
||||
class SearchRepository @Inject constructor(
|
||||
retrofit: Retrofit,
|
||||
private val serverHealth: ServerHealthController,
|
||||
private val serverHealth: NetworkStatusController,
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val albumDao: CachedAlbumDao,
|
||||
private val artistDao: CachedArtistDao,
|
||||
@@ -35,7 +35,8 @@ class SearchRepository @Inject constructor(
|
||||
private val api: SearchApi = retrofit.create()
|
||||
|
||||
suspend fun search(query: String): SearchOutcome = when (serverHealth.state.value) {
|
||||
ServerHealth.Healthy -> SearchOutcome(remoteSearch(query), localOnly = false)
|
||||
ServerHealth.Healthy, ServerHealth.Unstable ->
|
||||
SearchOutcome(remoteSearch(query), localOnly = false)
|
||||
ServerHealth.Offline, ServerHealth.ServerDown ->
|
||||
SearchOutcome(localSearch(query), localOnly = true)
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Routes a deliberate pull-to-refresh into a /healthz recheck so the connection
|
||||
* banner clears within seconds rather than waiting for the next poll — even on
|
||||
* cache-only screens whose own refresh never touches the network. Backs
|
||||
* [PullToRefreshScaffold].
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PullRefreshNetworkViewModel @Inject constructor(
|
||||
private val networkStatus: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
fun recheck() = networkStatus.recheck()
|
||||
}
|
||||
+6
-1
@@ -10,12 +10,15 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wraps [content] in a Material3 PullToRefreshBox so the user can
|
||||
* swipe-down to trigger [onRefresh]. The wrapper manages the
|
||||
* `isRefreshing` indicator while [onRefresh] is in flight.
|
||||
* `isRefreshing` indicator while [onRefresh] is in flight. Every pull also
|
||||
* fires a /healthz recheck (via [PullRefreshNetworkViewModel]) so a stale
|
||||
* connection banner clears promptly on a deliberate user refresh.
|
||||
*
|
||||
* [onRefresh] is suspend: pass `{ viewModel.refresh().join() }` so the
|
||||
* indicator hides exactly when the underlying coroutine completes,
|
||||
@@ -32,6 +35,7 @@ import kotlinx.coroutines.launch
|
||||
fun PullToRefreshScaffold(
|
||||
onRefresh: suspend () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
netVm: PullRefreshNetworkViewModel = hiltViewModel(),
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var isRefreshing by remember { mutableStateOf(false) }
|
||||
@@ -42,6 +46,7 @@ fun PullToRefreshScaffold(
|
||||
scope.launch {
|
||||
isRefreshing = true
|
||||
try {
|
||||
netVm.recheck() // deliberate pull → re-probe the server now
|
||||
onRefresh()
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
|
||||
/**
|
||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||
* is blank or unresolvable.
|
||||
* is blank/unresolvable, while the image is still loading, and when the
|
||||
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||
* which the "You might like" row hits often).
|
||||
*/
|
||||
@Composable
|
||||
fun ServerImage(
|
||||
@@ -22,12 +32,26 @@ fun ServerImage(
|
||||
val resolved = resolveServerUrl(url)
|
||||
if (resolved == null) {
|
||||
fallback()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
// Track Coil's load state so the fallback doubles as a placeholder
|
||||
// (loading) and an error state (404 / unreachable) — not just a
|
||||
// null-URL guard, which left present-but-failing URLs blank.
|
||||
var state by remember(resolved) {
|
||||
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
||||
}
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = resolved,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = contentScale,
|
||||
onState = { state = it },
|
||||
)
|
||||
if (state is AsyncImagePainter.State.Loading ||
|
||||
state is AsyncImagePainter.State.Error
|
||||
) {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +61,11 @@ fun TrackRow(
|
||||
trailing: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val offlineUnavailable = LocalServerHealth.current != ServerHealth.Healthy &&
|
||||
trackId !in LocalCachedTrackIds.current
|
||||
val health = LocalServerHealth.current
|
||||
// Unstable is non-gating — only a real gating state dims uncached rows.
|
||||
val offlineUnavailable =
|
||||
(health == ServerHealth.Offline || health == ServerHealth.ServerDown) &&
|
||||
trackId !in LocalCachedTrackIds.current
|
||||
val titleColor = if (nowPlaying) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
|
||||
@@ -7,8 +7,8 @@ import retrofit2.http.GET
|
||||
/**
|
||||
* Retrofit interface for `GET /healthz` — unauthenticated health probe
|
||||
* returning the server's running version + the minimum client version
|
||||
* it'll talk to. Used by [com.fabledsword.minstrel.update.data.VersionCheckController]
|
||||
* to surface the VersionTooOld banner.
|
||||
* it'll talk to. Polled by [com.fabledsword.minstrel.connectivity.NetworkStatusController]
|
||||
* for both reachability and the VersionTooOld banner.
|
||||
*/
|
||||
interface HealthzApi {
|
||||
@GET("healthz")
|
||||
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
import com.fabledsword.minstrel.BuildConfig
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.update.api.HealthzApi
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import retrofit2.Retrofit
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private const val POLL_INTERVAL_MS = 5 * 60 * 1000L
|
||||
|
||||
// Hysteresis on the reachable signal. Flipping internalReachable to false on
|
||||
// the very first /healthz failure produced two false positives:
|
||||
// 1. App startup — AuthStore.baseUrl loads from Room asynchronously, so the
|
||||
// first runOnce() can fire against AuthStore.DEFAULT_BASE_URL
|
||||
// ("http://localhost:8080") before the real server URL has hydrated.
|
||||
// 2. Deployments whose reverse proxy routes only /api/* to the Go server —
|
||||
// /healthz never reaches the handler, so the user sees a permanent
|
||||
// "Server unreachable" banner even though all real /api/* calls succeed.
|
||||
// Requiring 3 consecutive failures (~15 min at the 5-min poll cadence) ensures
|
||||
// the banner only fires on sustained, real unreachability — and a single
|
||||
// success at any point resets the counter so transient hiccups self-clear.
|
||||
private const val REACHABILITY_FAILURE_THRESHOLD = 3
|
||||
|
||||
/**
|
||||
* Result of the most recent /healthz version-compatibility check.
|
||||
* `Skipped` means the server didn't include `min_client_version`
|
||||
* (partial deploy or older server) and the UI should not gate.
|
||||
*/
|
||||
enum class VersionResult { OK, TOO_OLD, SKIPPED }
|
||||
|
||||
/**
|
||||
* Polls /healthz periodically and exposes the current
|
||||
* [VersionResult] for the shell's VersionTooOld banner. Soft-fails
|
||||
* on network errors — keeps the last-known result rather than
|
||||
* showing a misleading "too old" on a connectivity blip.
|
||||
*
|
||||
* Constructed at app launch via the construct-the-singleton trick
|
||||
* in [com.fabledsword.minstrel.MinstrelApplication].
|
||||
*/
|
||||
@Singleton
|
||||
class VersionCheckController @Inject constructor(
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||
|
||||
private val internal = MutableStateFlow(VersionResult.SKIPPED)
|
||||
val result: StateFlow<VersionResult> = internal.asStateFlow()
|
||||
|
||||
// Whether the most recent /healthz poll reached the server. Separate from
|
||||
// VersionResult because "unreachable" and "version mismatch" drive
|
||||
// different UX (offline banner vs version-too-old banner). Optimistic
|
||||
// initial value -- the first poll fires within seconds of app launch and
|
||||
// we don't want a "server down" flash before we've actually tried.
|
||||
// Consumed by ServerHealthController to compose with ConnectivityObserver
|
||||
// for the tri-state offline / server-down / healthy signal.
|
||||
private val internalReachable = MutableStateFlow(true)
|
||||
val reachable: StateFlow<Boolean> = internalReachable.asStateFlow()
|
||||
|
||||
private var consecutiveFailures = 0
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (true) {
|
||||
runOnce()
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot recheck, bypassing the poll cadence. Used by the banner's "Check now" button. */
|
||||
fun recheck() {
|
||||
scope.launch { runOnce() }
|
||||
}
|
||||
|
||||
private suspend fun runOnce() {
|
||||
val outcome = runCatching { api.check() }
|
||||
val response = outcome.getOrNull()
|
||||
if (response == null) {
|
||||
consecutiveFailures++
|
||||
if (consecutiveFailures >= REACHABILITY_FAILURE_THRESHOLD &&
|
||||
internalReachable.value
|
||||
) {
|
||||
Timber.w(
|
||||
"/healthz unreachable for %d consecutive polls — flipping reachable=false",
|
||||
consecutiveFailures,
|
||||
)
|
||||
internalReachable.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!internalReachable.value) {
|
||||
Timber.i("/healthz recovered after %d failures", consecutiveFailures)
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
internalReachable.value = true
|
||||
val min = response.minClientVersion
|
||||
internal.value = when {
|
||||
min.isEmpty() -> VersionResult.SKIPPED
|
||||
isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
|
||||
else -> VersionResult.OK
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.fabledsword.minstrel.update.data
|
||||
|
||||
/**
|
||||
* Result of the most recent /healthz version-compatibility check.
|
||||
* `SKIPPED` means the server didn't include `min_client_version`
|
||||
* (partial deploy or older server) and the UI should not gate.
|
||||
*/
|
||||
enum class VersionResult { OK, TOO_OLD, SKIPPED }
|
||||
+4
-4
@@ -2,21 +2,21 @@ package com.fabledsword.minstrel.update.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.fabledsword.minstrel.update.data.VersionCheckController
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.update.data.VersionResult
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Tiny VM that exposes the [VersionCheckController]'s current
|
||||
* Tiny VM that exposes the [NetworkStatusController]'s current
|
||||
* [VersionResult] plus its recheck trigger for the banner button.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class VersionTooOldViewModel @Inject constructor(
|
||||
private val controller: VersionCheckController,
|
||||
private val controller: NetworkStatusController,
|
||||
@Suppress("UnusedPrivateProperty") savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
val result: StateFlow<VersionResult> = controller.result
|
||||
val result: StateFlow<VersionResult> = controller.versionResult
|
||||
fun recheck() = controller.recheck()
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.fabledsword.minstrel.connectivity
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ReachabilityMachineTest {
|
||||
|
||||
private fun machine() = ReachabilityMachine()
|
||||
|
||||
@Test
|
||||
fun `starts healthy`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no link is offline regardless of probes`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = false)
|
||||
m.onProbeFailure(nowMs = 1)
|
||||
assertEquals(ServerHealth.Offline, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `single probe failure is unstable not down`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
assertEquals(ServerHealth.Unstable, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `probe success from unstable recovers to healthy`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
m.onSuccess()
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `op success from unstable recovers to healthy`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
m.onSuccess() // a successful stream read / API 2xx is self-proving
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two op failures plus a failed probe escalate immediately`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
m.onOpFailure(nowMs = 1_500) // corroboration reached
|
||||
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `op failures with a successful probe stay healthy (track-specific)`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
m.onOpFailure(nowMs = 1_500)
|
||||
m.onSuccess() // arbiter says server is fine
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale op failures do not corroborate`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onOpFailure(nowMs = 0)
|
||||
m.onOpFailure(nowMs = 1_000)
|
||||
// both op failures are now older than the corroboration window:
|
||||
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1)
|
||||
assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sustained failure backstop escalates after the window`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000) // unstable, streak starts
|
||||
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // sustained ≥ backstop
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `link restored keeps last-known down until a fresh result`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
m.onProbeFailure(nowMs = 1_000 + ESCALATE_AFTER_MS) // ServerDown
|
||||
m.onLinkChange(up = false)
|
||||
assertEquals(ServerHealth.Offline, m.health())
|
||||
m.onLinkChange(up = true)
|
||||
// link back but no fresh probe result yet — last known reachability was down:
|
||||
assertEquals(ServerHealth.ServerDown, m.health())
|
||||
m.onSuccess()
|
||||
assertEquals(ServerHealth.Healthy, m.health())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unstable does not gate — it is not offline or serverdown`() {
|
||||
val m = machine()
|
||||
m.onLinkChange(up = true)
|
||||
m.onProbeFailure(nowMs = 1_000)
|
||||
val health = m.health()
|
||||
assertTrue(health != ServerHealth.Offline && health != ServerHealth.ServerDown)
|
||||
assertEquals(ServerHealth.Unstable, health)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.fabledsword.minstrel.home.ui
|
||||
|
||||
import com.fabledsword.minstrel.models.PlaylistRef
|
||||
import com.fabledsword.minstrel.models.SystemPlaylistsStatus
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BuildPlaylistsRowTest {
|
||||
|
||||
private fun user(id: String, cached: Boolean) =
|
||||
PlaylistRef(id = id, userId = "u", name = id, trackCount = 1, fullyCached = cached)
|
||||
|
||||
@Test
|
||||
fun `offline row leads with pools then available before greyed`() {
|
||||
val owned = listOf(user("partial", cached = false), user("full", cached = true))
|
||||
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = true)
|
||||
|
||||
assertTrue(row[0] is PlaylistRowItem.OfflinePool)
|
||||
assertTrue(row[1] is PlaylistRowItem.OfflinePool)
|
||||
// Fully-cached "full" comes before the greyed "partial".
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertEquals(listOf("full", "partial"), reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline greys a refreshable system playlist even when fully cached`() {
|
||||
val forYou = PlaylistRef(
|
||||
id = "fy",
|
||||
userId = "u",
|
||||
name = "For You",
|
||||
systemVariant = "for_you",
|
||||
trackCount = 1,
|
||||
fullyCached = true,
|
||||
)
|
||||
val row = buildPlaylistsRow(
|
||||
listOf(forYou, user("u1", cached = true)),
|
||||
SystemPlaylistsStatus(),
|
||||
offline = true,
|
||||
)
|
||||
// The fully-cached user playlist is available; the refreshable system
|
||||
// mix needs the server, so it greys out and sorts after.
|
||||
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||
assertEquals(listOf("u1", "fy"), reals)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `offline row drops building-pending placeholders`() {
|
||||
val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = true)
|
||||
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `online row has no offline pools and keeps system-slot placeholders`() {
|
||||
val row = buildPlaylistsRow(emptyList(), SystemPlaylistsStatus(), offline = false)
|
||||
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
||||
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,32 @@ class RemotePlayerStateTest {
|
||||
assertFalse(state.recordPollFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play intent survives a SOAP error so a failed play can resume locally`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.applyError(RuntimeException("connect timeout"))
|
||||
// isPlaying is cleared by the error, but the operator's intent persists.
|
||||
assertFalse(state.isPlaying)
|
||||
assertTrue(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pausing clears play intent`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.setPlayIntent(false)
|
||||
assertFalse(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears play intent`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.reset()
|
||||
assertFalse(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DROP_THRESHOLD = 30
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
/**
|
||||
* Locks in the transport-command retry semantics behind the locked-phone
|
||||
* Sonos drop fix: a transient IO stall (WiFi power-save) must be retried, a
|
||||
* SOAP fault from a responding renderer must NOT be retried, and an
|
||||
* exhausted retry must rethrow rather than loop forever. Without this, a
|
||||
* single failed command falsely reverted Sonos playback to the phone.
|
||||
*/
|
||||
class RetryTransientIoTest {
|
||||
|
||||
@Test
|
||||
fun `returns immediately on first success`() = runTest {
|
||||
var calls = 0
|
||||
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
"ok"
|
||||
}
|
||||
assertEquals("ok", result)
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retries transient IO failure then succeeds`() = runTest {
|
||||
var calls = 0
|
||||
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
if (calls < 2) throw IOException("connect timeout")
|
||||
"ok"
|
||||
}
|
||||
assertEquals("ok", result)
|
||||
assertEquals(2, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rethrows IO failure after exhausting attempts`() = runTest {
|
||||
var calls = 0
|
||||
assertFailsWith<IOException> {
|
||||
retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
throw IOException("still asleep")
|
||||
}
|
||||
}
|
||||
assertEquals(3, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not retry a SOAP fault from a responding renderer`() = runTest {
|
||||
var calls = 0
|
||||
assertFailsWith<SoapFaultException> {
|
||||
retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
throw SoapFaultException("718", "Invalid InstanceID")
|
||||
}
|
||||
}
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class IdleRevertDecisionTest {
|
||||
|
||||
@Test
|
||||
fun `engaged and paused and not armed arms the timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.ARM,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engaged and paused and already armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resuming playback cancels an armed timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.CANCEL,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playing with no timer armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disengaging UPnP cancels an armed timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.CANCEL,
|
||||
idleRevertAction(upnpEngaged = false, isPlaying = false, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disengaged with no timer armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = false, isPlaying = true, armed = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.fabledsword.minstrel.playlists.data
|
||||
|
||||
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PlaylistsRepositoryCacheMergeTest {
|
||||
|
||||
private fun playlist(id: String, trackCount: Int) =
|
||||
CachedPlaylistEntity(id = id, userId = "u", name = id, trackCount = trackCount)
|
||||
|
||||
@Test
|
||||
fun `fully cached when every member track is resident`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 3)),
|
||||
counts = listOf(PlaylistCachedCount("p", cachedCount = 3)),
|
||||
)
|
||||
assertTrue(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not fully cached when only some tracks are resident`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 3)),
|
||||
counts = listOf(PlaylistCachedCount("p", cachedCount = 2)),
|
||||
)
|
||||
assertFalse(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `not fully cached when no tracks are resident (missing count row)`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 3)),
|
||||
counts = emptyList(),
|
||||
)
|
||||
assertFalse(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty playlist is never fully cached`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("p", trackCount = 0)),
|
||||
counts = listOf(PlaylistCachedCount("p", cachedCount = 0)),
|
||||
)
|
||||
assertFalse(out.single().fullyCached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each playlist gets its own cache verdict`() {
|
||||
val out = mergePlaylistsWithCache(
|
||||
rows = listOf(playlist("full", 2), playlist("partial", 2)),
|
||||
counts = listOf(
|
||||
PlaylistCachedCount("full", cachedCount = 2),
|
||||
PlaylistCachedCount("partial", cachedCount = 1),
|
||||
),
|
||||
)
|
||||
val byId = out.associateBy { it.id }
|
||||
assertEquals(true, byId.getValue("full").fullyCached)
|
||||
assertEquals(false, byId.getValue("partial").fullyCached)
|
||||
}
|
||||
}
|
||||
+18
-8
@@ -6,17 +6,27 @@ in-app update flow (#397).
|
||||
|
||||
## Production
|
||||
|
||||
CI populates this directory on tag releases:
|
||||
1. `flutter.yml` builds `app-release.apk` and attaches it to the
|
||||
Gitea release as `minstrel-<TAG>.apk`.
|
||||
2. `release.yml` waits for that asset to appear, downloads it into
|
||||
this directory as `minstrel.apk`, writes the tag string to
|
||||
`minstrel.apk.version`, and `docker buildx build` includes both
|
||||
via `COPY client/ /app/client/`.
|
||||
CI populates this directory in `release.yml` (no Flutter — the client is
|
||||
native Android, built by the `android-release` job):
|
||||
|
||||
- **Tag release (`v*`):** the `android-release` job builds + signs the
|
||||
APK, uploads it as a workflow artifact, and attaches it to the Gitea
|
||||
release as `minstrel-<TAG>.apk`. The `image-release` job
|
||||
(`needs: android-release`) downloads that artifact into this directory
|
||||
as `minstrel.apk` and writes the computed versionName to
|
||||
`minstrel.apk.version`.
|
||||
- **Main push (non-tag `:latest` / `:main`):** no APK is built, but the
|
||||
image still moves `:latest`, so `image-release` pulls the most-recent
|
||||
release's signed APK from the Gitea API and reconstructs its exact
|
||||
versionName — keeping the in-app update channel alive on `:latest`
|
||||
instead of stripping it until the next tag.
|
||||
|
||||
Either way, `docker buildx build` bakes both files into the image via
|
||||
`COPY client/ /app/client/`.
|
||||
|
||||
## Development
|
||||
|
||||
Empty directory works fine — the endpoints return 404 and the Flutter
|
||||
Empty directory works fine — the endpoints return 404 and the Android
|
||||
update banner stays hidden ("no update channel" graceful degradation).
|
||||
|
||||
To smoke-test the update flow locally, drop a real APK + a version
|
||||
|
||||
+54
-5
@@ -5,12 +5,15 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
@@ -53,6 +56,35 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency
|
||||
// full delta walk, catching anything the watcher missed (inotify watch-limit
|
||||
// exhaustion, dropped events). Fixed — there is no operator configuration.
|
||||
const safetyNetScanInterval = 12 * time.Hour
|
||||
|
||||
// runSafetyNetScans ticks a delta RunScan at safetyNetScanInterval until ctx
|
||||
// is cancelled, deferring to TryStartScan's in-flight guard so it never
|
||||
// collides with a manual, startup, or watcher-driven scan.
|
||||
func runSafetyNetScans(ctx context.Context, pool *pgxpool.Pool, scanner *library.Scanner,
|
||||
enricher *coverart.Enricher, logger *slog.Logger, scanCfg library.RunScanConfig,
|
||||
) {
|
||||
ticker := time.NewTicker(safetyNetScanInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
started, existing, err := library.TryStartScan(ctx, pool, scanner, enricher, logger, scanCfg)
|
||||
if err != nil {
|
||||
logger.Error("safety-net scan: try start failed", "err", err)
|
||||
} else if !started && existing != nil {
|
||||
logger.Info("safety-net scan skipped — prior still in flight",
|
||||
"in_flight_id", existing.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||
flag.Parse()
|
||||
@@ -210,7 +242,12 @@ func run() error {
|
||||
// active user's daily build at 03:00 in their stored timezone.
|
||||
// Replaces the 24h-anchored cron loop (removed in the next commit
|
||||
// of this arc).
|
||||
playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir)
|
||||
playlistScheduler, err := playlists.NewScheduler(
|
||||
pool,
|
||||
logger.With("component", "playlist_scheduler"),
|
||||
cfg.Storage.DataDir,
|
||||
bus,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init playlist scheduler: %w", err)
|
||||
}
|
||||
@@ -237,12 +274,24 @@ func run() error {
|
||||
ArtistEnrichCap: -1,
|
||||
DataDir: cfg.Storage.DataDir,
|
||||
}
|
||||
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
|
||||
scanner, coverEnricher, scanCfg)
|
||||
scheduler.Start(ctx)
|
||||
// Filesystem watcher: near-instant pickup of new/changed files via
|
||||
// fsnotify, scanning just the affected paths + enriching their albums
|
||||
// inline. Replaces the removed configurable scan scheduler.
|
||||
watcher := library.NewWatcher(scanner, coverEnricher,
|
||||
logger.With("component", "watcher"), cfg.Library.ScanPaths)
|
||||
go func() {
|
||||
if werr := watcher.Run(ctx); werr != nil {
|
||||
logger.Warn("library watcher exited", "err", werr)
|
||||
}
|
||||
}()
|
||||
// Safety-net delta walk backstops the watcher (inotify limits / dropped
|
||||
// events) at a fixed low frequency. No operator config.
|
||||
go runSafetyNetScans(ctx, pool, scanner, coverEnricher,
|
||||
logger.With("component", "scan_safetynet"), scanCfg)
|
||||
|
||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||||
srv.Bus = bus
|
||||
srv.PlaylistScheduler = playlistScheduler
|
||||
srv.StreamSecret = cfg.StreamSecret
|
||||
|
||||
+7
-2
@@ -26,8 +26,13 @@ library:
|
||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||
scan_paths: []
|
||||
# Kick off a scan on server startup. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
|
||||
scan_on_startup: false
|
||||
# Kick off a scan on server startup. On by default so a fresh install
|
||||
# populates its library on first boot with no manual step. Scans are
|
||||
# incremental (unchanged files are skipped by mtime), so the cost on
|
||||
# every later restart is just a directory walk, not a full re-index.
|
||||
# Set false if you'd rather scan only on a schedule / via the admin UI.
|
||||
# Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
|
||||
scan_on_startup: true
|
||||
|
||||
# Per-instance branding. Operators can rename their instance ("Family
|
||||
# Jukebox", "Office Music", etc.) and override the OG share-preview
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# README screenshots
|
||||
|
||||
Drop the files below into this directory with these **exact names** — the
|
||||
root `README.md` already references them, so no Markdown editing is needed.
|
||||
Export as PNG. Capture while logged in as admin and *after* the first
|
||||
library scan so nothing looks empty; keep the same browser width and theme
|
||||
across all shots for a consistent look.
|
||||
|
||||
| Filename | Route | What to capture |
|
||||
|---|---|---|
|
||||
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
|
||||
| `register.png` | `/register` | The account-creation form on a fresh instance |
|
||||
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
|
||||
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
|
||||
| `android-download.png` | `/settings` | The "Install the Android app" download card |
|
||||
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
|
||||
|
||||
Tip: to control display width in the README, you can swap the Markdown
|
||||
`` for an HTML tag, e.g. `<img src="docs/screenshots/home.png" width="800" alt="...">`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
@@ -25,5 +26,6 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
|
||||
@@ -23,6 +23,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
type scanScheduleResp struct {
|
||||
Mode string `json:"mode"`
|
||||
IntervalHours *int `json:"interval_hours"`
|
||||
TimeOfDay *string `json:"time_of_day"`
|
||||
WeeklyDay *int `json:"weekly_day"`
|
||||
NextScheduledAt *string `json:"next_scheduled_at"`
|
||||
}
|
||||
|
||||
type scanScheduleReq struct {
|
||||
Mode string `json:"mode"`
|
||||
IntervalHours *int `json:"interval_hours"`
|
||||
TimeOfDay *string `json:"time_of_day"`
|
||||
WeeklyDay *int `json:"weekly_day"`
|
||||
}
|
||||
|
||||
func (h *handlers) handleGetScanSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
q := dbq.New(h.pool)
|
||||
row, err := q.GetScanSchedule(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
h.logger.Error("admin: scan_schedule singleton row missing")
|
||||
writeErr(w, apierror.InternalMsg("schedule row missing", errors.New("schedule row missing")))
|
||||
return
|
||||
}
|
||||
writeErrWithLog(w, h.logger, "admin: get scan schedule", apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, h.scheduler.NextFire()))
|
||||
}
|
||||
|
||||
func (h *handlers) handlePatchScanSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
var req scanScheduleReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateScheduleReq(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("validation", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
if err := q.UpdateScanSchedule(r.Context(), dbq.UpdateScanScheduleParams{
|
||||
Mode: req.Mode,
|
||||
IntervalHours: pgInt32Ptr(req.IntervalHours),
|
||||
TimeOfDay: req.TimeOfDay,
|
||||
WeeklyDay: pgInt32Ptr(req.WeeklyDay),
|
||||
}); err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: update scan schedule", apierror.InternalMsg("update failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
h.scheduler.Refresh()
|
||||
|
||||
row, err := q.GetScanSchedule(r.Context())
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: re-read scan schedule", apierror.InternalMsg("re-read failed", err))
|
||||
return
|
||||
}
|
||||
cfg := library.ScheduleConfig{Mode: row.Mode}
|
||||
if row.IntervalHours != nil {
|
||||
cfg.IntervalHours = int(*row.IntervalHours)
|
||||
}
|
||||
if row.TimeOfDay != nil {
|
||||
cfg.TimeOfDay = *row.TimeOfDay
|
||||
}
|
||||
if row.WeeklyDay != nil {
|
||||
cfg.WeeklyDay = int(*row.WeeklyDay)
|
||||
}
|
||||
next := cfg.NextFire(time.Now())
|
||||
|
||||
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, next))
|
||||
}
|
||||
|
||||
func validateScheduleReq(req *scanScheduleReq) error {
|
||||
switch req.Mode {
|
||||
case "off":
|
||||
req.IntervalHours = nil
|
||||
req.TimeOfDay = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "interval":
|
||||
if req.IntervalHours == nil || *req.IntervalHours <= 0 {
|
||||
return fmt.Errorf("interval_hours required and > 0 when mode=interval")
|
||||
}
|
||||
req.TimeOfDay = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "daily":
|
||||
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
|
||||
return fmt.Errorf("time_of_day required when mode=daily")
|
||||
}
|
||||
req.IntervalHours = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "weekly":
|
||||
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
|
||||
return fmt.Errorf("time_of_day required when mode=weekly")
|
||||
}
|
||||
if req.WeeklyDay == nil || *req.WeeklyDay < 1 || *req.WeeklyDay > 7 {
|
||||
return fmt.Errorf("weekly_day required and in [1, 7] when mode=weekly")
|
||||
}
|
||||
req.IntervalHours = nil
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid mode %q (want off / interval / daily / weekly)", req.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func scanScheduleRespFromRow(row dbq.GetScanScheduleRow, nextFire time.Time) scanScheduleResp {
|
||||
resp := scanScheduleResp{Mode: row.Mode}
|
||||
if row.IntervalHours != nil {
|
||||
v := int(*row.IntervalHours)
|
||||
resp.IntervalHours = &v
|
||||
}
|
||||
if row.TimeOfDay != nil {
|
||||
v := *row.TimeOfDay
|
||||
resp.TimeOfDay = &v
|
||||
}
|
||||
if row.WeeklyDay != nil {
|
||||
v := int(*row.WeeklyDay)
|
||||
resp.WeeklyDay = &v
|
||||
}
|
||||
if !nextFire.IsZero() {
|
||||
s := nextFire.UTC().Format(time.RFC3339)
|
||||
resp.NextScheduledAt = &s
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func pgInt32Ptr(src *int) *int32 {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
v := int32(*src)
|
||||
return &v
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
func newAdminScheduleRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin())
|
||||
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
||||
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Get_DefaultIsOff(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedget", "pw", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp scanScheduleResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Mode != "off" {
|
||||
t.Errorf("Mode = %q, want off", resp.Mode)
|
||||
}
|
||||
if resp.NextScheduledAt != nil {
|
||||
t.Errorf("NextScheduledAt = %v, want nil for mode=off", resp.NextScheduledAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_DailyMode(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedpatch", "pw", true)
|
||||
|
||||
body := `{"mode":"daily","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp scanScheduleResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Mode != "daily" {
|
||||
t.Errorf("Mode = %q, want daily", resp.Mode)
|
||||
}
|
||||
if resp.TimeOfDay == nil || *resp.TimeOfDay != "03:00" {
|
||||
t.Errorf("TimeOfDay = %v, want '03:00'", resp.TimeOfDay)
|
||||
}
|
||||
if resp.NextScheduledAt == nil {
|
||||
t.Errorf("NextScheduledAt = nil, want a future ISO timestamp")
|
||||
} else {
|
||||
next, err := time.Parse(time.RFC3339, *resp.NextScheduledAt)
|
||||
if err != nil {
|
||||
t.Errorf("NextScheduledAt parse: %v", err)
|
||||
} else if local := next.Local(); local.Hour() != 3 || local.Minute() != 0 {
|
||||
// Handler computes next-fire from time.Now() in the host zone, so
|
||||
// the local-time hour is what's stable across CI runners regardless
|
||||
// of TZ; the wire timestamp is UTC but parses back to the same
|
||||
// instant either way.
|
||||
t.Errorf("NextScheduledAt local time = %v, want 03:00 in host zone", local)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_WeeklyMissingDay_400(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedweekly", "pw", true)
|
||||
|
||||
body := `{"mode":"weekly","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_OffNormalizes(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedoff", "pw", true)
|
||||
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`UPDATE scan_schedule SET mode='daily', time_of_day='03:00' WHERE id=true`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
body := `{"mode":"off","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var timeOfDay *string
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT time_of_day FROM scan_schedule WHERE id=true`,
|
||||
).Scan(&timeOfDay); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if timeOfDay != nil {
|
||||
t.Errorf("time_of_day = %v, want nil after mode=off normalize", timeOfDay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_NonAdmin_403(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
user := seedUser(t, pool, "schednonadmin", "pw", false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -28,7 +28,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, 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, 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()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -42,7 +42,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
coverSettings: coverSettings,
|
||||
scanner: scanner,
|
||||
scanCfg: scanCfg,
|
||||
scheduler: scheduler,
|
||||
dataDir: dataDir,
|
||||
mailer: sender,
|
||||
eventbus: bus,
|
||||
@@ -75,6 +74,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
authed.Get("/me", h.handleGetMe)
|
||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
||||
authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
||||
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
||||
authed.Get("/me/history", h.handleGetMyHistory)
|
||||
@@ -87,6 +87,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/artists", h.handleListArtists)
|
||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
||||
authed.Get("/artists/{id}/similar", h.handleGetSimilarArtists)
|
||||
authed.Get("/artists/{id}/top-tracks", h.handleGetArtistTopTracks)
|
||||
authed.Get("/albums/{id}", h.handleGetAlbum)
|
||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||
@@ -167,8 +169,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
|
||||
admin.Get("/scan/status", h.handleGetScanStatus)
|
||||
admin.Post("/scan/run", h.handleTriggerScan)
|
||||
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
||||
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
||||
|
||||
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
||||
|
||||
@@ -222,7 +222,6 @@ type handlers struct {
|
||||
coverSettings *coverart.SettingsService
|
||||
scanner *library.Scanner
|
||||
scanCfg library.RunScanConfig
|
||||
scheduler *library.Scheduler
|
||||
dataDir string
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
|
||||
@@ -88,6 +88,21 @@ func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.
|
||||
})
|
||||
}
|
||||
|
||||
// publishSystemRebuilt notifies the owner's clients that their system
|
||||
// playlists (and You-might-like rows) were regenerated, so they invalidate
|
||||
// the home / system-playlist providers and a stale active queue can re-pull.
|
||||
// Mirrors the daily scheduler's event; fired here from the manual refresh.
|
||||
func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
|
||||
if h.eventbus == nil {
|
||||
return
|
||||
}
|
||||
h.eventbus.Publish(eventbus.Event{
|
||||
Kind: "playlist.system_rebuilt",
|
||||
UserID: uuidToString(userID),
|
||||
Data: map[string]any{},
|
||||
})
|
||||
}
|
||||
|
||||
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
|
||||
// the request's original requester so their /requests page reflects the
|
||||
// new state without manual refresh. Admin actors (approve / reject) still
|
||||
|
||||
@@ -30,6 +30,8 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
|
||||
RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)),
|
||||
MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)),
|
||||
LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)),
|
||||
YouMightLikeAlbums: make([]AlbumRef, 0, len(data.YouMightLikeAlbums)),
|
||||
YouMightLikeArtists: make([]ArtistRef, 0, len(data.YouMightLikeArtists)),
|
||||
}
|
||||
for _, row := range data.RecentlyAddedAlbums {
|
||||
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||
@@ -46,6 +48,12 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
|
||||
for _, row := range data.LastPlayedArtists {
|
||||
out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
||||
}
|
||||
for _, row := range data.YouMightLikeAlbums {
|
||||
out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||
}
|
||||
for _, row := range data.YouMightLikeArtists {
|
||||
out.YouMightLikeArtists = append(out.YouMightLikeArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -78,6 +86,8 @@ func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) {
|
||||
RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)),
|
||||
MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)),
|
||||
LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)),
|
||||
YouMightLikeAlbums: make([]string, 0, len(data.YouMightLikeAlbums)),
|
||||
YouMightLikeArtists: make([]string, 0, len(data.YouMightLikeArtists)),
|
||||
}
|
||||
for _, row := range data.RecentlyAddedAlbums {
|
||||
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID))
|
||||
@@ -94,6 +104,12 @@ func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) {
|
||||
for _, row := range data.LastPlayedArtists {
|
||||
out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID))
|
||||
}
|
||||
for _, row := range data.YouMightLikeAlbums {
|
||||
out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, uuidToString(row.Album.ID))
|
||||
}
|
||||
for _, row := range data.YouMightLikeArtists {
|
||||
out.YouMightLikeArtists = append(out.YouMightLikeArtists, uuidToString(row.Artist.ID))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,85 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// similarArtistsDefaultLimit caps the artist-detail "similar artists" strip;
|
||||
// artistTopTracksDefaultLimit caps the per-user "top tracks" panel.
|
||||
const (
|
||||
similarArtistsDefaultLimit = 12
|
||||
artistTopTracksDefaultLimit = 5
|
||||
)
|
||||
|
||||
// handleGetSimilarArtists implements GET /api/artists/{id}/similar. Returns
|
||||
// in-library artists similar to {id} (ranked by similarity score) as a flat
|
||||
// ArtistRef list with cover + album count. Empty when the similarity ingest
|
||||
// has no matches yet; 404 when the artist doesn't exist.
|
||||
func (h *handlers) handleGetSimilarArtists(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, apierror.NotFound("artist"))
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get artist for similar", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
rows, err := q.ListSimilarArtistsForArtist(r.Context(), dbq.ListSimilarArtistsForArtistParams{
|
||||
SeedArtistID: id, ResultLimit: similarArtistsDefaultLimit,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list similar artists", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]ArtistRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleGetArtistTopTracks implements GET /api/artists/{id}/top-tracks. Returns
|
||||
// the current user's most-played tracks for {id} (skips excluded, quarantine
|
||||
// filtered). Empty when the user hasn't played this artist; 404 when the
|
||||
// artist doesn't exist.
|
||||
func (h *handlers) handleGetArtistTopTracks(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, apierror.NotFound("artist"))
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get artist for top tracks", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
rows, err := q.ListMostPlayedTracksForArtist(r.Context(), dbq.ListMostPlayedTracksForArtistParams{
|
||||
ArtistID: id, UserID: user.ID, ResultLimit: artistTopTracksDefaultLimit,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list artist top tracks", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]TrackRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
|
||||
// the online source for the client's always-present "Shuffle all"
|
||||
// (#427 S4). N random tracks across the whole library, per-user
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
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)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
const (
|
||||
recMetricsDefaultDays = 30
|
||||
recMetricsMaxDays = 365
|
||||
)
|
||||
|
||||
// recommendationMetric is one recommendation surface's outcomes.
|
||||
type recommendationMetric struct {
|
||||
Source string `json:"source"` // 'for_you' | 'discover' | mixes
|
||||
Plays int64 `json:"plays"` // plays launched from this surface
|
||||
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]
|
||||
}
|
||||
|
||||
type recommendationMetricsResp struct {
|
||||
WindowDays int `json:"window_days"`
|
||||
Sources []recommendationMetric `json:"sources"`
|
||||
}
|
||||
|
||||
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
|
||||
// caller over the last `days` (default 30, capped at 365), so the operator can
|
||||
// see which recommendation surfaces are landing and tune the taste weights.
|
||||
// Only plays tagged with a system-playlist source count; library/radio plays
|
||||
// (no source) are excluded.
|
||||
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
|
||||
}
|
||||
|
||||
out := recommendationMetricsResp{
|
||||
WindowDays: days,
|
||||
Sources: make([]recommendationMetric, 0, len(rows)),
|
||||
}
|
||||
for _, row := range rows {
|
||||
source := ""
|
||||
if row.Source != nil {
|
||||
source = *row.Source
|
||||
}
|
||||
var skipRate float64
|
||||
if row.Plays > 0 {
|
||||
skipRate = float64(row.Skips) / float64(row.Plays)
|
||||
}
|
||||
out.Sources = append(out.Sources, recommendationMetric{
|
||||
Source: source,
|
||||
Plays: row.Plays,
|
||||
Skips: row.Skips,
|
||||
SkipRate: skipRate,
|
||||
AvgCompletion: row.AvgCompletion,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// 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,108 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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 (library/radio play).
|
||||
func seedSourcedPlay(
|
||||
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
||||
source *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, completion_ratio, was_skipped)
|
||||
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
|
||||
userID, trackID, sessionID, source, 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(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"
|
||||
discover := "discover"
|
||||
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
|
||||
// discover: 1 play.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
|
||||
// library play (NULL source) — must be excluded.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, 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)
|
||||
}
|
||||
bySource := map[string]recommendationMetric{}
|
||||
for _, m := range resp.Sources {
|
||||
bySource[m.Source] = m
|
||||
}
|
||||
if _, present := bySource[""]; present {
|
||||
t.Error("NULL-source (library) plays should be excluded")
|
||||
}
|
||||
fy, ok := bySource["for_you"]
|
||||
if !ok {
|
||||
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)
|
||||
}
|
||||
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
|
||||
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@ func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Re
|
||||
writeErr(w, apierror.InternalMsg("build failed", err))
|
||||
return
|
||||
}
|
||||
// #968: announce the rebuild so the user's other clients refresh.
|
||||
h.publishSystemRebuilt(user.ID)
|
||||
q := dbq.New(h.pool)
|
||||
v := kind
|
||||
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||
|
||||
@@ -108,6 +108,7 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
|
||||
|
||||
@@ -116,6 +116,11 @@ type HomePayload struct {
|
||||
RediscoverArtists []ArtistRef `json:"rediscover_artists"`
|
||||
MostPlayedTracks []TrackRef `json:"most_played_tracks"`
|
||||
LastPlayedArtists []ArtistRef `json:"last_played_artists"`
|
||||
// You-might-like: in-library albums/artists predicted from the user's
|
||||
// listening that they don't actively spin. Built daily; gated on a
|
||||
// minimum listening history so a thin profile gets empty rows.
|
||||
YouMightLikeAlbums []AlbumRef `json:"you_might_like_albums"`
|
||||
YouMightLikeArtists []ArtistRef `json:"you_might_like_artists"`
|
||||
}
|
||||
|
||||
// HomeIndexPayload is the response body of GET /api/home/index — the
|
||||
@@ -134,6 +139,8 @@ type HomeIndexPayload struct {
|
||||
RediscoverArtists []string `json:"rediscover_artists"`
|
||||
MostPlayedTracks []string `json:"most_played_tracks"`
|
||||
LastPlayedArtists []string `json:"last_played_artists"`
|
||||
YouMightLikeAlbums []string `json:"you_might_like_albums"`
|
||||
YouMightLikeArtists []string `json:"you_might_like_artists"`
|
||||
}
|
||||
|
||||
// HistoryEvent is one play in a user's listening history. Used by
|
||||
|
||||
@@ -98,6 +98,7 @@ type RecommendationConfig struct {
|
||||
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"`
|
||||
@@ -119,18 +120,26 @@ func Default() Config {
|
||||
SkipMaxDurationPlayedMs: 30000,
|
||||
},
|
||||
Recommendation: RecommendationConfig{
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 1.0,
|
||||
JitterMagnitude: 0.1,
|
||||
ContextWeight: 2.0,
|
||||
SimilarityWeight: 2.0,
|
||||
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,
|
||||
RadioSize: 50,
|
||||
RadioSizeMax: 200,
|
||||
},
|
||||
Library: LibraryConfig{
|
||||
// On by default so a fresh install populates its library on
|
||||
// first boot without the operator hunting for a "scan now"
|
||||
// button. Scans are incremental (unchanged files skipped by
|
||||
// mtime), so every later restart is just a cheap directory walk.
|
||||
ScanOnStartup: true,
|
||||
CoverArtFromMBCAA: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -97,7 +97,8 @@ func TestEnvOverrides(t *testing.T) {
|
||||
func TestLibraryEnvOverrides(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true")
|
||||
// Default is true; set false so the override path is actually exercised.
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "false")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
@@ -112,8 +113,8 @@ func TestLibraryEnvOverrides(t *testing.T) {
|
||||
t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p)
|
||||
}
|
||||
}
|
||||
if !cfg.Library.ScanOnStartup {
|
||||
t.Error("scan_on_startup = false, want true")
|
||||
if cfg.Library.ScanOnStartup {
|
||||
t.Error("scan_on_startup = true, want false (env override of the true default)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -439,15 +439,6 @@ type ScanRun struct {
|
||||
ArtistArtEnrich []byte
|
||||
}
|
||||
|
||||
type ScanSchedule struct {
|
||||
ID bool
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type ScrobbleQueue struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
@@ -506,6 +497,20 @@ type SystemPlaylistRun struct {
|
||||
LastError *string
|
||||
}
|
||||
|
||||
type TasteProfileArtist struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Weight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileTag struct {
|
||||
UserID pgtype.UUID
|
||||
Tag string
|
||||
Weight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
ID pgtype.UUID
|
||||
Title string
|
||||
@@ -558,3 +563,17 @@ type UserInvite struct {
|
||||
RedeemedAt pgtype.Timestamptz
|
||||
RedeemedBy pgtype.UUID
|
||||
}
|
||||
|
||||
type YouMightLikeAlbum struct {
|
||||
UserID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
Rank int32
|
||||
BuiltAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type YouMightLikeArtist struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Rank int32
|
||||
BuiltAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
@@ -97,6 +97,83 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMostPlayedTracksForArtist = `-- name: ListMostPlayedTracksForArtist :many
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
FROM play_events
|
||||
WHERE user_id = $2 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE t.artist_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $2 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type ListMostPlayedTracksForArtistParams struct {
|
||||
ArtistID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
ResultLimit int32
|
||||
}
|
||||
|
||||
type ListMostPlayedTracksForArtistRow struct {
|
||||
Track Track
|
||||
AlbumTitle string
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Top tracks for one artist by this user's completed-play count (skips
|
||||
// excluded, quarantine filtered). Same projection as
|
||||
// ListMostPlayedTracksForUser plus an artist_id filter, so the handler
|
||||
// reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName).
|
||||
func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMostPlayedTracksForArtistParams) ([]ListMostPlayedTracksForArtistRow, error) {
|
||||
rows, err := q.db.Query(ctx, listMostPlayedTracksForArtist, arg.ArtistID, arg.UserID, arg.ResultLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListMostPlayedTracksForArtistRow
|
||||
for rows.Next() {
|
||||
var i ListMostPlayedTracksForArtistRow
|
||||
if err := rows.Scan(
|
||||
&i.Track.ID,
|
||||
&i.Track.Title,
|
||||
&i.Track.AlbumID,
|
||||
&i.Track.ArtistID,
|
||||
&i.Track.TrackNumber,
|
||||
&i.Track.DiscNumber,
|
||||
&i.Track.DurationMs,
|
||||
&i.Track.FilePath,
|
||||
&i.Track.FileSize,
|
||||
&i.Track.FileFormat,
|
||||
&i.Track.Bitrate,
|
||||
&i.Track.Mbid,
|
||||
&i.Track.Genre,
|
||||
&i.Track.AddedAt,
|
||||
&i.Track.UpdatedAt,
|
||||
&i.AlbumTitle,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
@@ -660,6 +737,17 @@ likes_overlap AS (
|
||||
ORDER BY random()
|
||||
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 (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -670,6 +758,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM similar_artists
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -686,6 +775,7 @@ FROM (
|
||||
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 likes_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
@@ -713,6 +803,7 @@ type LoadRadioCandidatesV2Params struct {
|
||||
Limit_3 int32
|
||||
Limit_4 int32
|
||||
Limit_5 int32
|
||||
Limit_6 int32
|
||||
}
|
||||
|
||||
type LoadRadioCandidatesV2Row struct {
|
||||
@@ -724,11 +815,15 @@ type LoadRadioCandidatesV2Row struct {
|
||||
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,
|
||||
// $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.
|
||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||
@@ -742,6 +837,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
arg.Limit_3,
|
||||
arg.Limit_4,
|
||||
arg.Limit_5,
|
||||
arg.Limit_6,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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,
|
||||
count(*)::bigint AS plays,
|
||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||
COALESCE(
|
||||
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
||||
0)::float8 AS avg_completion
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.source IS NOT NULL
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
GROUP BY pe.source
|
||||
ORDER BY plays DESC
|
||||
`
|
||||
|
||||
type RecommendationSourceMetricsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Column2 float64
|
||||
}
|
||||
|
||||
type RecommendationSourceMetricsForUserRow struct {
|
||||
Source *string
|
||||
Plays int64
|
||||
Skips int64
|
||||
AvgCompletion float64
|
||||
}
|
||||
|
||||
// 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 system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
||||
// NULL for library / radio / user-playlist plays, which are excluded here.
|
||||
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||
// mean completion ratio over plays that recorded one (0 when none did).
|
||||
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.Plays,
|
||||
&i.Skips,
|
||||
&i.AvgCompletion,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: scan_schedule.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getScanSchedule = `-- name: GetScanSchedule :one
|
||||
SELECT mode, interval_hours, time_of_day, weekly_day, updated_at
|
||||
FROM scan_schedule WHERE id = true
|
||||
`
|
||||
|
||||
type GetScanScheduleRow struct {
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// M7 recurring-scans: returns the singleton schedule row. Used by the
|
||||
// scheduler goroutine on boot and config-refresh, and by the admin GET
|
||||
// handler.
|
||||
func (q *Queries) GetScanSchedule(ctx context.Context) (GetScanScheduleRow, error) {
|
||||
row := q.db.QueryRow(ctx, getScanSchedule)
|
||||
var i GetScanScheduleRow
|
||||
err := row.Scan(
|
||||
&i.Mode,
|
||||
&i.IntervalHours,
|
||||
&i.TimeOfDay,
|
||||
&i.WeeklyDay,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateScanSchedule = `-- name: UpdateScanSchedule :exec
|
||||
UPDATE scan_schedule
|
||||
SET mode = $1,
|
||||
interval_hours = $2,
|
||||
time_of_day = $3,
|
||||
weekly_day = $4,
|
||||
updated_at = now()
|
||||
WHERE id = true
|
||||
`
|
||||
|
||||
type UpdateScanScheduleParams struct {
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
}
|
||||
|
||||
// Admin PATCH replaces the whole row; CHECK constraints enforce validity.
|
||||
// The application normalizes mode='off' to NULL the per-mode fields
|
||||
// before calling this.
|
||||
func (q *Queries) UpdateScanSchedule(ctx context.Context, arg UpdateScanScheduleParams) error {
|
||||
_, err := q.db.Exec(ctx, updateScanSchedule,
|
||||
arg.Mode,
|
||||
arg.IntervalHours,
|
||||
arg.TimeOfDay,
|
||||
arg.WeeklyDay,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -154,6 +154,77 @@ func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit i
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSimilarArtistsForArtist = `-- name: ListSimilarArtistsForArtist :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,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM (
|
||||
SELECT artist_b_id, max(score) AS sim_score
|
||||
FROM artist_similarity
|
||||
WHERE artist_a_id = $1
|
||||
GROUP BY artist_b_id
|
||||
) s
|
||||
JOIN artists ON artists.id = s.artist_b_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 s.sim_score DESC, artists.sort_name
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListSimilarArtistsForArtistParams struct {
|
||||
SeedArtistID pgtype.UUID
|
||||
ResultLimit int32
|
||||
}
|
||||
|
||||
type ListSimilarArtistsForArtistRow struct {
|
||||
Artist Artist
|
||||
CoverAlbumID pgtype.UUID
|
||||
AlbumCount int64
|
||||
}
|
||||
|
||||
// In-library artists similar to a seed artist, ranked by best similarity
|
||||
// score across sources (deduped per candidate). cover_album_id + album_count
|
||||
// mirror ListArtistsAlphaWithCovers so the strip renders identical cards.
|
||||
func (q *Queries) ListSimilarArtistsForArtist(ctx context.Context, arg ListSimilarArtistsForArtistParams) ([]ListSimilarArtistsForArtistRow, error) {
|
||||
rows, err := q.db.Query(ctx, listSimilarArtistsForArtist, arg.SeedArtistID, arg.ResultLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListSimilarArtistsForArtistRow
|
||||
for rows.Next() {
|
||||
var i ListSimilarArtistsForArtistRow
|
||||
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 upsertArtistSimilarity = `-- name: UpsertArtistSimilarity :exec
|
||||
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
|
||||
VALUES ($1, $2, $3, 'listenbrainz', now())
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: taste_profile.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteTasteProfileArtistsForUser = `-- name: DeleteTasteProfileArtistsForUser :exec
|
||||
DELETE FROM taste_profile_artists WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTasteProfileArtistsForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
|
||||
DELETE FROM taste_profile_tags WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteTasteProfileTagsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTasteProfileTagsForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileArtist = `-- name: InsertTasteProfileArtist :exec
|
||||
INSERT INTO taste_profile_artists (user_id, artist_id, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertTasteProfileArtistParams struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteProfileArtistParams) error {
|
||||
_, err := q.db.Exec(ctx, insertTasteProfileArtist, arg.UserID, arg.ArtistID, arg.Weight)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
|
||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertTasteProfileTagParams struct {
|
||||
UserID pgtype.UUID
|
||||
Tag string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertTasteProfileTag(ctx context.Context, arg InsertTasteProfileTagParams) error {
|
||||
_, err := q.db.Exec(ctx, insertTasteProfileTag, arg.UserID, arg.Tag, arg.Weight)
|
||||
return err
|
||||
}
|
||||
|
||||
const listLikedArtistIDsForUser = `-- name: ListLikedArtistIDsForUser :many
|
||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
||||
`
|
||||
|
||||
// Artists the user has explicitly liked; feeds the artist-like bonus.
|
||||
func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedArtistIDsForUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var artist_id pgtype.UUID
|
||||
if err := rows.Scan(&artist_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, artist_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
||||
SELECT t.artist_id, t.genre
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
WHERE gl.user_id = $1
|
||||
`
|
||||
|
||||
type ListLikedTrackTasteInputsForUserRow struct {
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
}
|
||||
|
||||
// (artist_id, genre) for each track the user has explicitly liked. Feeds the
|
||||
// track-like bonus into the liked track's artist and tags.
|
||||
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListLikedTrackTasteInputsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListLikedTrackTasteInputsForUserRow
|
||||
if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
|
||||
|
||||
SELECT
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
LEAST(GREATEST(
|
||||
COALESCE(pe.completion_ratio,
|
||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
||||
0), 0), 1)::float8 AS completion,
|
||||
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
`
|
||||
|
||||
type ListPlayEngagementInputsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Column2 float64
|
||||
}
|
||||
|
||||
type ListPlayEngagementInputsForUserRow struct {
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
Completion float64
|
||||
AgeDays float64
|
||||
}
|
||||
|
||||
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
|
||||
// weights in Go (graded engagement + decay + like bonus) and atomic-replaces
|
||||
// them here. Read queries serve "top taste" lookups (used in phase 2).
|
||||
// One row per play in the decay-relevant window. completion is the play's
|
||||
// completion ratio (precomputed column when present, else duration_played /
|
||||
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
// is split into tags in Go. Quarantined tracks are excluded.
|
||||
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListPlayEngagementInputsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListPlayEngagementInputsForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.ArtistID,
|
||||
&i.Genre,
|
||||
&i.Completion,
|
||||
&i.AgeDays,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileArtistsForUser = `-- name: ListTasteProfileArtistsForUser :many
|
||||
SELECT artist_id, weight
|
||||
FROM taste_profile_artists
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListTasteProfileArtistsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListTasteProfileArtistsForUserRow struct {
|
||||
ArtistID pgtype.UUID
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// Top-weighted taste artists (phase-2 consumption + tests).
|
||||
func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTasteProfileArtistsForUserParams) ([]ListTasteProfileArtistsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTasteProfileArtistsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTasteProfileArtistsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListTasteProfileArtistsForUserRow
|
||||
if err := rows.Scan(&i.ArtistID, &i.Weight); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
|
||||
SELECT tag, weight
|
||||
FROM taste_profile_tags
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListTasteProfileTagsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListTasteProfileTagsForUserRow struct {
|
||||
Tag string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) ListTasteProfileTagsForUser(ctx context.Context, arg ListTasteProfileTagsForUserParams) ([]ListTasteProfileTagsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTasteProfileTagsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTasteProfileTagsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListTasteProfileTagsForUserRow
|
||||
if err := rows.Scan(&i.Tag, &i.Weight); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: you_might_like.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countListeningSignalForUser = `-- name: CountListeningSignalForUser :one
|
||||
|
||||
SELECT
|
||||
count(DISTINCT pe.track_id)::bigint AS distinct_tracks,
|
||||
count(DISTINCT t.artist_id)::bigint AS distinct_artists
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
`
|
||||
|
||||
type CountListeningSignalForUserRow struct {
|
||||
DistinctTracks int64
|
||||
DistinctArtists int64
|
||||
}
|
||||
|
||||
// "You might like" Home rows (#790). The daily build computes ranked
|
||||
// album/artist IDs in Go (similarity roll-up + cold-start gate) and
|
||||
// atomic-replaces them here; /api/home reads them back hydrated.
|
||||
// Cold-start gate. Returns the breadth of the user's real listening:
|
||||
// distinct unskipped tracks played and distinct artists played. The
|
||||
// build skips You-might-like entirely below a threshold (a thin history
|
||||
// yields near-random roll-ups). was_skipped=false so a user spamming
|
||||
// next can't inflate the signal.
|
||||
func (q *Queries) CountListeningSignalForUser(ctx context.Context, userID pgtype.UUID) (CountListeningSignalForUserRow, error) {
|
||||
row := q.db.QueryRow(ctx, countListeningSignalForUser, userID)
|
||||
var i CountListeningSignalForUserRow
|
||||
err := row.Scan(&i.DistinctTracks, &i.DistinctArtists)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteYouMightLikeAlbumsForUser = `-- name: DeleteYouMightLikeAlbumsForUser :exec
|
||||
DELETE FROM you_might_like_albums WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteYouMightLikeAlbumsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteYouMightLikeAlbumsForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteYouMightLikeArtistsForUser = `-- name: DeleteYouMightLikeArtistsForUser :exec
|
||||
DELETE FROM you_might_like_artists WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteYouMightLikeArtistsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteYouMightLikeArtistsForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertYouMightLikeAlbum = `-- name: InsertYouMightLikeAlbum :exec
|
||||
INSERT INTO you_might_like_albums (user_id, album_id, rank)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertYouMightLikeAlbumParams struct {
|
||||
UserID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
Rank int32
|
||||
}
|
||||
|
||||
func (q *Queries) InsertYouMightLikeAlbum(ctx context.Context, arg InsertYouMightLikeAlbumParams) error {
|
||||
_, err := q.db.Exec(ctx, insertYouMightLikeAlbum, arg.UserID, arg.AlbumID, arg.Rank)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertYouMightLikeArtist = `-- name: InsertYouMightLikeArtist :exec
|
||||
INSERT INTO you_might_like_artists (user_id, artist_id, rank)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertYouMightLikeArtistParams struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Rank int32
|
||||
}
|
||||
|
||||
func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMightLikeArtistParams) error {
|
||||
_, err := q.db.Exec(ctx, insertYouMightLikeArtist, arg.UserID, arg.ArtistID, arg.Rank)
|
||||
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
|
||||
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
|
||||
JOIN albums ON albums.id = yml.album_id
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE yml.user_id = $1
|
||||
ORDER BY yml.rank
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListYouMightLikeAlbumsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListYouMightLikeAlbumsForUserRow struct {
|
||||
Album Album
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Read path. Same projection as ListRediscoverAlbumsForUser so the API
|
||||
// layer reuses albumRefFrom(row.Album, row.ArtistName, …). Ordered by
|
||||
// the rank persisted at build time. Final cross-section dedup (vs Most
|
||||
// Played / Rediscover) and the output cap land in the Go layer
|
||||
// (internal/recommendation/home.go).
|
||||
func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYouMightLikeAlbumsForUserParams) ([]ListYouMightLikeAlbumsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listYouMightLikeAlbumsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListYouMightLikeAlbumsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListYouMightLikeAlbumsForUserRow
|
||||
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 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
|
||||
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 you_might_like_artists yml
|
||||
JOIN artists ON artists.id = yml.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
|
||||
WHERE yml.user_id = $1
|
||||
ORDER BY yml.rank
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListYouMightLikeArtistsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListYouMightLikeArtistsForUserRow struct {
|
||||
Artist Artist
|
||||
CoverAlbumID pgtype.UUID
|
||||
AlbumCount int64
|
||||
}
|
||||
|
||||
// Read path. Same projection as ListRediscoverArtistsForUser (embeds the
|
||||
// artist + a representative cover_album_id + album_count) so the API
|
||||
// layer reuses artistRefFromCovered. Ordered by persisted rank.
|
||||
func (q *Queries) ListYouMightLikeArtistsForUser(ctx context.Context, arg ListYouMightLikeArtistsForUserParams) ([]ListYouMightLikeArtistsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listYouMightLikeArtistsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListYouMightLikeArtistsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListYouMightLikeArtistsForUserRow
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Recreate the scan_schedule singleton (mirror of 0019) so the drop is
|
||||
-- reversible. The application no longer reads or writes this table.
|
||||
CREATE TABLE scan_schedule (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
mode text NOT NULL DEFAULT 'off',
|
||||
interval_hours int,
|
||||
time_of_day text,
|
||||
weekly_day int,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT scan_schedule_singleton CHECK (id = true),
|
||||
CONSTRAINT scan_schedule_mode_check
|
||||
CHECK (mode IN ('off', 'interval', 'daily', 'weekly')),
|
||||
CONSTRAINT scan_schedule_interval_check
|
||||
CHECK (mode != 'interval' OR (interval_hours IS NOT NULL AND interval_hours > 0)),
|
||||
CONSTRAINT scan_schedule_daily_check
|
||||
CHECK (mode != 'daily' OR time_of_day IS NOT NULL),
|
||||
CONSTRAINT scan_schedule_weekly_check
|
||||
CHECK (mode != 'weekly' OR (time_of_day IS NOT NULL AND weekly_day IS NOT NULL AND weekly_day BETWEEN 1 AND 7))
|
||||
);
|
||||
|
||||
INSERT INTO scan_schedule (id) VALUES (true);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Retire the configurable scan scheduler. New-file pickup is now handled by
|
||||
-- the fsnotify watcher + a hardcoded safety-net delta walk (cmd/minstrel),
|
||||
-- so the operator-configurable schedule (and its admin UI) is gone.
|
||||
DROP TABLE IF EXISTS scan_schedule;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Reverse 0034_you_might_like.up.sql.
|
||||
DROP TABLE IF EXISTS you_might_like_artists;
|
||||
DROP TABLE IF EXISTS you_might_like_albums;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 0034_you_might_like.up.sql — "You might like" Home rows (#790).
|
||||
--
|
||||
-- Per-user ranked lists of IN-LIBRARY albums/artists the listener
|
||||
-- doesn't actively spin but is predicted to enjoy, derived from the
|
||||
-- same similarity + like-weighted candidate engine that powers For-You
|
||||
-- (rolled up from track scores to album/artist). Built in the daily
|
||||
-- 3am BuildSystemPlaylists pass and atomic-replaced, exactly like the
|
||||
-- system playlists; read back by /api/home.
|
||||
--
|
||||
-- Two thin tables (id + rank) rather than denormalized snapshots: the
|
||||
-- Home read path hydrates album/artist refs through the existing
|
||||
-- albums/artists joins, so a later metadata edit is reflected without
|
||||
-- a rebuild. CASCADE on user/album/artist deletes keeps them clean.
|
||||
|
||||
CREATE TABLE you_might_like_albums (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||
rank integer NOT NULL,
|
||||
built_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, album_id)
|
||||
);
|
||||
|
||||
-- Read path orders by rank within a user; the index serves it directly.
|
||||
CREATE INDEX you_might_like_albums_user_rank_idx
|
||||
ON you_might_like_albums (user_id, rank);
|
||||
|
||||
CREATE TABLE you_might_like_artists (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
||||
rank integer NOT NULL,
|
||||
built_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, artist_id)
|
||||
);
|
||||
|
||||
CREATE INDEX you_might_like_artists_user_rank_idx
|
||||
ON you_might_like_artists (user_id, rank);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Reverse 0035_taste_profile.up.sql.
|
||||
DROP TABLE IF EXISTS taste_profile_tags;
|
||||
DROP TABLE IF EXISTS taste_profile_artists;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- 0035_taste_profile.up.sql — per-user taste profile (#796 phase 1).
|
||||
--
|
||||
-- A persistent, decaying model of each user's taste, rebuilt daily from
|
||||
-- graded play engagement (completion + skip position) plus explicit
|
||||
-- likes. Two facet tables: weighted artists and weighted genre tags.
|
||||
-- Weight is a signed float — positive = drawn to, negative = (passively)
|
||||
-- avoided; magnitude reflects decayed engagement. Phase 1 only BUILDS
|
||||
-- these; consumption (candidate sourcing + scoring) lands in phase 2.
|
||||
--
|
||||
-- CASCADE on user/artist deletes keeps them clean. Indexed by
|
||||
-- (user_id, weight DESC) to serve "top taste artists/tags" reads cheaply.
|
||||
|
||||
CREATE TABLE taste_profile_artists (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
||||
weight double precision NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, artist_id)
|
||||
);
|
||||
|
||||
CREATE INDEX taste_profile_artists_user_weight_idx
|
||||
ON taste_profile_artists (user_id, weight DESC);
|
||||
|
||||
CREATE TABLE taste_profile_tags (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tag text NOT NULL,
|
||||
weight double precision NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX taste_profile_tags_user_weight_idx
|
||||
ON taste_profile_tags (user_id, weight DESC);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user