Compare commits
58 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 |
@@ -2,8 +2,8 @@ name: release
|
|||||||
|
|
||||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||||
#
|
#
|
||||||
# push to main → :main and :latest (no APK bundled)
|
# push to main → :main and :latest (latest-release APK bundled)
|
||||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (APK bundled)
|
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
|
||||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||||
#
|
#
|
||||||
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
||||||
@@ -20,6 +20,14 @@ name: release
|
|||||||
# happens in the same android-release job, so the Release-page download
|
# happens in the same android-release job, so the Release-page download
|
||||||
# link and the in-image bundled APK are both populated atomically.
|
# link and the in-image bundled APK are both populated atomically.
|
||||||
#
|
#
|
||||||
|
# :latest always carries an APK. Because every main push also moves
|
||||||
|
# :latest (not just tags), a main build with no APK would silently strip
|
||||||
|
# the in-app update channel off :latest until the next release. So on
|
||||||
|
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||||
|
# and reconstructs its exact versionName (tag + commit-count, the same
|
||||||
|
# formula android-release bakes in) for the version sidecar — no rebuild,
|
||||||
|
# just rebundle. Tag builds keep bundling their own freshly-built APK.
|
||||||
|
#
|
||||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||||
# lives in android.yml and runs independently on every push.
|
# lives in android.yml and runs independently on every push.
|
||||||
|
|
||||||
@@ -181,6 +189,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history + tags so non-tag :latest builds can resolve the
|
||||||
|
# latest release tag's commit count and reconstruct the bundled
|
||||||
|
# APK's exact versionName (see "Bundle latest release APK" below).
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
|
||||||
- name: Detect buildable project
|
- name: Detect buildable project
|
||||||
id: guard
|
id: guard
|
||||||
@@ -221,9 +235,8 @@ jobs:
|
|||||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||||
|
|
||||||
- name: Download signed APK artifact
|
- name: Download signed APK artifact
|
||||||
# Tag pushes only — android-release just produced this. Main
|
# Tag pushes only — android-release just produced this. Non-tag
|
||||||
# pushes skip and the image ships with empty client/ (the
|
# builds take the "Bundle latest release APK" path below instead.
|
||||||
# /api/client/version endpoint then returns 404 by design).
|
|
||||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
@@ -247,6 +260,42 @@ jobs:
|
|||||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||||
ls -lh client/
|
ls -lh client/
|
||||||
|
|
||||||
|
- name: Bundle latest release APK (non-tag :latest builds)
|
||||||
|
# Main pushes don't build an APK, but they DO move :latest — so
|
||||||
|
# without this the in-app update channel would vanish from :latest
|
||||||
|
# until the next tag. Pull the most-recent release's signed APK and
|
||||||
|
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
|
||||||
|
# TAG) — identical to android-release's formula) so the version
|
||||||
|
# sidecar the server hands clients matches the installed build.
|
||||||
|
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||||
|
# version — if no release / APK asset / tag-count can be resolved.
|
||||||
|
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
REPO="${GITHUB_REPOSITORY}"
|
||||||
|
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||||
|
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
||||||
|
if [ -z "${REL_JSON}" ]; then
|
||||||
|
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||||
|
fi
|
||||||
|
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
|
||||||
|
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
|
||||||
|
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||||
|
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||||
|
fi
|
||||||
|
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
|
||||||
|
if [ -z "${COUNT}" ]; then
|
||||||
|
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
||||||
|
fi
|
||||||
|
VERSION_NAME="${TAG#v}.${COUNT}"
|
||||||
|
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||||
|
echo "${VERSION_NAME}" > client/minstrel.apk.version
|
||||||
|
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
|
||||||
|
ls -lh client/
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
if: steps.guard.outputs.ready == 'true'
|
if: steps.guard.outputs.ready == 'true'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
|||||||
|
|
||||||
> State and intelligence belong on the server, not the client.
|
> State and intelligence belong on the server, not the client.
|
||||||
|
|
||||||
<!-- TODO: screenshot of the home page -->
|
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
|
||||||
|
|
||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
|||||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
||||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||||
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356).
|
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
@@ -24,10 +24,18 @@ services:
|
|||||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||||
ports: ['4533:4533']
|
ports: ['4533:4533']
|
||||||
volumes:
|
volumes:
|
||||||
|
# Your music library. Point ./music at wherever your audio files
|
||||||
|
# live. Mounted read-only — Minstrel never writes to your library.
|
||||||
- ./music:/music:ro
|
- ./music:/music:ro
|
||||||
- minstrel-data:/data
|
# Generated data: playlist cover collages, artist art, caches.
|
||||||
|
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||||
|
# sets to /app/data — keep this mount on /app/data or your cache
|
||||||
|
# won't survive a container recreate.
|
||||||
|
- minstrel-data:/app/data
|
||||||
environment:
|
environment:
|
||||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||||
|
# Colon-separated library roots to scan; must match the container
|
||||||
|
# path of the read-only music mount above (/music here).
|
||||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||||
depends_on: [db]
|
depends_on: [db]
|
||||||
|
|
||||||
@@ -37,6 +45,8 @@ services:
|
|||||||
POSTGRES_USER: minstrel
|
POSTGRES_USER: minstrel
|
||||||
POSTGRES_PASSWORD: minstrel
|
POSTGRES_PASSWORD: minstrel
|
||||||
POSTGRES_DB: minstrel
|
POSTGRES_DB: minstrel
|
||||||
|
# Postgres data dir — users, likes, play history, sessions, settings.
|
||||||
|
# The one volume you must never lose; back it up with pg_dump.
|
||||||
volumes: [pgdata:/var/lib/postgresql/data]
|
volumes: [pgdata:/var/lib/postgresql/data]
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
@@ -48,7 +58,29 @@ volumes:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
|
## First run
|
||||||
|
|
||||||
|
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
|
||||||
|
|
||||||
|
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
|
||||||
|
|
||||||
|
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
||||||
|
|
||||||
|
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
|
||||||
|
|
||||||
|
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
||||||
|
|
||||||
|
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
|
||||||
|
|
||||||
|
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
|
||||||
|
|
||||||
|
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
|
||||||
|
|
||||||
|
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The "Install the Android app" download card in Settings"></a>
|
||||||
|
|
||||||
|
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
|
||||||
|
|
||||||
|
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
|
||||||
|
|
||||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||||
|
|
||||||
@@ -57,7 +89,7 @@ For the full configuration surface, see [`config.example.yaml`](./config.example
|
|||||||
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||||
|
|
||||||
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||||
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
|
||||||
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||||
|
|
||||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||||
@@ -66,8 +98,13 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
|||||||
|
|
||||||
## Updating
|
## Updating
|
||||||
|
|
||||||
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.
|
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||||
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
|
||||||
|
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||||
|
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
|
||||||
|
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||||
|
|
||||||
|
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||||
|
|
||||||
## Specs
|
## Specs
|
||||||
|
|
||||||
|
|||||||
Vendored
+5
@@ -14,6 +14,11 @@ import kotlinx.datetime.Instant
|
|||||||
* - "rediscover_artists"
|
* - "rediscover_artists"
|
||||||
* - "most_played_tracks"
|
* - "most_played_tracks"
|
||||||
* - "last_played_artists"
|
* - "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
|
* `entityType` is "album" | "artist" | "track" — dispatches hydration
|
||||||
* to the right per-entity endpoint when a tile is rendered.
|
* 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 PLAY_OFFLINE: String = "play_offline"
|
||||||
const val REQUEST_CANCEL: String = "request_cancel"
|
const val REQUEST_CANCEL: String = "request_cancel"
|
||||||
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
|
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 {
|
private suspend fun insertUserDriven(kind: String, payload: String): Long {
|
||||||
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
||||||
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
||||||
@@ -223,6 +241,19 @@ data class PlayOfflinePayload(
|
|||||||
val source: String? = null,
|
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
|
* Persisted payload for `MutationKind.REQUEST_CANCEL` — the
|
||||||
* `POST /api/requests/{id}/cancel` call lost during a connectivity
|
* `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
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
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.PlaylistsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
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.models.wire.PlayOfflineRequest
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
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 com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import retrofit2.HttpException
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -34,25 +43,24 @@ import javax.inject.Singleton
|
|||||||
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
|
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
|
||||||
* re-fires each call against the raw Retrofit API (NOT the high-level
|
* re-fires each call against the raw Retrofit API (NOT the high-level
|
||||||
* Repository, which would re-enqueue on failure → infinite loop), and
|
* 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:
|
* Triggered by:
|
||||||
* - app start (MinstrelApplication @Inject forces the singleton's
|
* - app start (MinstrelApplication @Inject forces the singleton's init)
|
||||||
* init block to subscribe)
|
* - AuthStore.sessionCookie transitioning to non-null (sign-in / cold
|
||||||
* - every time AuthStore.sessionCookie transitions to a non-null
|
* start with a persisted cookie)
|
||||||
* value (sign-in, app launch with 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
|
* Single in-flight via the [mutex]; back-to-back triggers collapse into one
|
||||||
* into one drain pass. A proper connectivity-listener / WorkManager
|
* drain pass. No exponential backoff / max-attempts: the rows are few for
|
||||||
* job that re-tries on Wi-Fi-came-back is a follow-up; for MVP the
|
* typical use, and permanent failures are now dropped rather than retried
|
||||||
* sign-in + app-open triggers cover the common offline → online
|
* forever.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class MutationReplayer @Inject constructor(
|
class MutationReplayer @Inject constructor(
|
||||||
@@ -60,6 +68,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
private val json: Json,
|
private val json: Json,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val likesApi: LikesApi = retrofit.create()
|
private val likesApi: LikesApi = retrofit.create()
|
||||||
@@ -72,6 +81,8 @@ class MutationReplayer @Inject constructor(
|
|||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private enum class Outcome { SENT, DROP, RETRY }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
authStore.sessionCookie
|
||||||
@@ -79,6 +90,16 @@ class MutationReplayer @Inject constructor(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { drainSafe() }
|
.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. */
|
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
|
||||||
@@ -93,193 +114,185 @@ class MutationReplayer @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun drain() {
|
private suspend fun drain() {
|
||||||
val rows = dao.getAll()
|
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) {
|
for (row in rows) {
|
||||||
val sent = runCatching { dispatch(row) }.getOrDefault(false)
|
if (row.id in superseded) {
|
||||||
if (sent) {
|
|
||||||
dao.delete(row.id)
|
dao.delete(row.id)
|
||||||
} else {
|
continue
|
||||||
dao.recordAttempt(row.id, Clock.System.now())
|
}
|
||||||
|
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
|
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
|
||||||
* failure (transport, server error, decode). The replayer treats
|
* unknown kind/variant. Network / HTTP failures THROW and are classified
|
||||||
* false as "leave the row for next pass" — it doesn't try to
|
* by [outcomeFor] — dispatchers no longer swallow.
|
||||||
* distinguish permanent rejections from transient ones; a row
|
|
||||||
* that consistently fails will accumulate attempt count and
|
|
||||||
* lastAttemptAt for diagnostics.
|
|
||||||
*/
|
*/
|
||||||
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.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
||||||
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
||||||
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
||||||
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
||||||
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
||||||
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
||||||
|
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
else -> {
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
// Unknown kind — drop the row by claiming success so a
|
else -> Outcome.DROP
|
||||||
// stale schema entry can't wedge the queue forever.
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchLikeToggle(payload: String): Boolean {
|
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||||
val kindPath = when (decoded.entityType) {
|
val kindPath = when (decoded.entityType) {
|
||||||
LikesRepository.ENTITY_ARTIST -> "artists"
|
LikesRepository.ENTITY_ARTIST -> "artists"
|
||||||
LikesRepository.ENTITY_ALBUM -> "albums"
|
LikesRepository.ENTITY_ALBUM -> "albums"
|
||||||
LikesRepository.ENTITY_TRACK -> "tracks"
|
LikesRepository.ENTITY_TRACK -> "tracks"
|
||||||
else -> return true // unknown variant — drop.
|
else -> return Outcome.DROP
|
||||||
}
|
}
|
||||||
return try {
|
if (decoded.desiredState) {
|
||||||
if (decoded.desiredState) {
|
likesApi.like(kindPath, decoded.entityId)
|
||||||
likesApi.like(kindPath, decoded.entityId)
|
} else {
|
||||||
} else {
|
likesApi.unlike(kindPath, decoded.entityId)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
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 decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
|
||||||
val body = CreateRequestBody(
|
discoverApi.createRequest(
|
||||||
kind = decoded.kind,
|
CreateRequestBody(
|
||||||
artistMbid = decoded.artistMbid,
|
kind = decoded.kind,
|
||||||
artistName = decoded.artistName,
|
artistMbid = decoded.artistMbid,
|
||||||
albumMbid = decoded.albumMbid,
|
artistName = decoded.artistName,
|
||||||
albumTitle = decoded.albumTitle,
|
albumMbid = decoded.albumMbid,
|
||||||
trackMbid = decoded.trackMbid,
|
albumTitle = decoded.albumTitle,
|
||||||
trackTitle = decoded.trackTitle,
|
trackMbid = decoded.trackMbid,
|
||||||
|
trackTitle = decoded.trackTitle,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return try {
|
return Outcome.SENT
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchQuarantineUnflag(payload: String): Boolean {
|
private suspend fun dispatchQuarantineUnflag(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
|
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
|
||||||
return try {
|
quarantineApi.unflag(decoded.trackId)
|
||||||
quarantineApi.unflag(decoded.trackId)
|
return Outcome.SENT
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaylistAppend(payload: String): Boolean {
|
private suspend fun dispatchPlaylistAppend(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
|
||||||
return try {
|
playlistsApi.appendTracks(
|
||||||
playlistsApi.appendTracks(
|
playlistId = decoded.playlistId,
|
||||||
playlistId = decoded.playlistId,
|
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
||||||
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
)
|
||||||
)
|
return Outcome.SENT
|
||||||
true
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Intentional swallow: row stays queued; next drain pass retries.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchQuarantineFlag(payload: String): Boolean {
|
private suspend fun dispatchQuarantineFlag(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
|
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
|
||||||
return try {
|
quarantineApi.flag(
|
||||||
quarantineApi.flag(
|
FlagRequest(
|
||||||
FlagRequest(
|
trackId = decoded.trackId,
|
||||||
trackId = decoded.trackId,
|
reason = decoded.reason,
|
||||||
reason = decoded.reason,
|
notes = decoded.notes,
|
||||||
notes = decoded.notes,
|
),
|
||||||
),
|
)
|
||||||
)
|
return Outcome.SENT
|
||||||
true
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Intentional swallow: row stays queued; next drain pass retries.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlayOffline(payload: String): Boolean {
|
private suspend fun dispatchPlayOffline(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
||||||
return try {
|
eventsApi.playOffline(
|
||||||
eventsApi.playOffline(
|
PlayOfflineRequest(
|
||||||
PlayOfflineRequest(
|
trackId = decoded.trackId,
|
||||||
trackId = decoded.trackId,
|
clientId = decoded.clientId,
|
||||||
clientId = decoded.clientId,
|
at = decoded.atIso,
|
||||||
at = decoded.atIso,
|
durationPlayedMs = decoded.durationPlayedMs,
|
||||||
durationPlayedMs = decoded.durationPlayedMs,
|
source = decoded.source,
|
||||||
source = decoded.source,
|
),
|
||||||
),
|
)
|
||||||
)
|
return Outcome.SENT
|
||||||
true
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Intentional swallow: row stays queued; next drain pass retries.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
|
||||||
return try {
|
requestsApi.cancel(decoded.requestId)
|
||||||
requestsApi.cancel(decoded.requestId)
|
return Outcome.SENT
|
||||||
true
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
// Intentional swallow: row stays queued; next drain pass retries.
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Boolean {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
return try {
|
playbackErrorsApi.report(
|
||||||
playbackErrorsApi.report(
|
PlaybackErrorReportRequest(
|
||||||
PlaybackErrorReportRequest(
|
trackId = decoded.trackId,
|
||||||
trackId = decoded.trackId,
|
kind = decoded.kind,
|
||||||
kind = decoded.kind,
|
detail = decoded.detail,
|
||||||
detail = decoded.detail,
|
clientId = decoded.clientId,
|
||||||
clientId = decoded.clientId,
|
),
|
||||||
),
|
)
|
||||||
)
|
return Outcome.SENT
|
||||||
true
|
}
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
private companion object {
|
||||||
) {
|
const val HTTP_CLIENT_ERR_MIN = 400
|
||||||
// Intentional swallow: row stays queued; next drain pass retries.
|
const val HTTP_CLIENT_ERR_MAX = 499
|
||||||
false
|
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.api.endpoints.SyncApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
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 com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.datetime.Clock
|
import kotlinx.datetime.Clock
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
@@ -50,10 +55,15 @@ class SyncController @Inject constructor(
|
|||||||
private val trackDao: CachedTrackDao,
|
private val trackDao: CachedTrackDao,
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) {
|
||||||
private val api: SyncApi = retrofit.create()
|
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 {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
authStore.sessionCookie
|
||||||
@@ -61,11 +71,25 @@ class SyncController @Inject constructor(
|
|||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { syncSafe() }
|
.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. */
|
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
||||||
suspend fun syncSafe() {
|
suspend fun syncSafe() {
|
||||||
runCatching { sync() }
|
if (!mutex.tryLock()) return
|
||||||
|
try {
|
||||||
|
runCatching { sync() }
|
||||||
|
} finally {
|
||||||
|
mutex.unlock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun sync() {
|
private suspend fun sync() {
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ import javax.inject.Singleton
|
|||||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
* pre-warms the top artists via [HomeArtistPrewarmer].
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@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(
|
class HomeRepository @Inject constructor(
|
||||||
private val homeIndexDao: CachedHomeIndexDao,
|
private val homeIndexDao: CachedHomeIndexDao,
|
||||||
private val metadataProvider: MetadataProvider,
|
private val metadataProvider: MetadataProvider,
|
||||||
@@ -76,6 +79,12 @@ class HomeRepository @Inject constructor(
|
|||||||
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
|
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,
|
* Pulls /api/home/index, replaces each cached_home_index section,
|
||||||
* and pre-warms the top artists. The section Flows re-fire on the
|
* 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_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
||||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
||||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
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>) {
|
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_REDISCOVER_ARTISTS = "rediscover_artists"
|
||||||
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
||||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ import kotlinx.coroutines.flow.combine
|
|||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -116,6 +117,8 @@ data class HomeSections(
|
|||||||
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||||
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
|
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
|
||||||
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||||
|
val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
|
||||||
|
val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val isAllEmpty: Boolean
|
val isAllEmpty: Boolean
|
||||||
get() = playlists.isEmpty() &&
|
get() = playlists.isEmpty() &&
|
||||||
@@ -123,7 +126,9 @@ data class HomeSections(
|
|||||||
rediscoverAlbums.isEmpty() &&
|
rediscoverAlbums.isEmpty() &&
|
||||||
rediscoverArtists.isEmpty() &&
|
rediscoverArtists.isEmpty() &&
|
||||||
mostPlayedTracks.isEmpty() &&
|
mostPlayedTracks.isEmpty() &&
|
||||||
lastPlayedArtists.isEmpty()
|
lastPlayedArtists.isEmpty() &&
|
||||||
|
youMightLikeAlbums.isEmpty() &&
|
||||||
|
youMightLikeArtists.isEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||||
@@ -135,6 +140,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository,
|
||||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||||
|
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
|
||||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -163,6 +169,15 @@ class HomeViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -289,13 +304,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Five home-section flows merged into the sections struct without
|
* The core five section flows merged into the sections struct. Split
|
||||||
* playlists; then merged with the playlists flow in [combineHomeFlows].
|
* out so each combine call stays inside the coroutines built-in arity
|
||||||
* Splitting it like this keeps each combine call inside the
|
* limit (5) and avoids the typed-vararg acrobatics a wider combine
|
||||||
* coroutines built-in arity limit (5) and avoids the typed-vararg
|
* across heterogeneous types would need. [observeHomeSections] then
|
||||||
* acrobatics needed for a 6-flow combine across heterogeneous types.
|
* folds in the two you-might-like flows, and [combineHomeFlows] the
|
||||||
|
* playlists flow.
|
||||||
*/
|
*/
|
||||||
private fun observeHomeSections() = combine(
|
private fun observeCoreSections() = combine(
|
||||||
homeRepository.observeRecentlyAddedAlbums(),
|
homeRepository.observeRecentlyAddedAlbums(),
|
||||||
homeRepository.observeRediscoverAlbums(),
|
homeRepository.observeRediscoverAlbums(),
|
||||||
homeRepository.observeRediscoverArtists(),
|
homeRepository.observeRediscoverArtists(),
|
||||||
@@ -311,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() =
|
private fun combineHomeFlows() =
|
||||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
||||||
val merged = sections.copy(playlists = playlists)
|
val merged = sections.copy(playlists = playlists)
|
||||||
@@ -463,6 +487,14 @@ private fun HomeSuccessContent(
|
|||||||
onPlayPlaylist = onPlayPlaylist,
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
youMightLikeSection(
|
||||||
|
albums = sections.youMightLikeAlbums,
|
||||||
|
artists = sections.youMightLikeArtists,
|
||||||
|
onAlbumClick = onAlbumClick,
|
||||||
|
onArtistClick = onArtistClick,
|
||||||
|
onPlayAlbum = onPlayAlbum,
|
||||||
|
onPlayArtist = onPlayArtist,
|
||||||
|
)
|
||||||
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
||||||
rediscoverSection(
|
rediscoverSection(
|
||||||
albums = sections.rediscoverAlbums,
|
albums = sections.rediscoverAlbums,
|
||||||
@@ -667,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
|
@Composable
|
||||||
private fun AlbumsRow(
|
private fun AlbumsRow(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
|
@file:OptIn(ExperimentalSerializationApi::class)
|
||||||
|
|
||||||
package com.fabledsword.minstrel.models.wire
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.EncodeDefault
|
||||||
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
@@ -15,7 +19,11 @@ import kotlinx.serialization.Serializable
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayStartedRequest(
|
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("track_id") val trackId: String,
|
||||||
@SerialName("client_id") val clientId: String,
|
@SerialName("client_id") val clientId: String,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
@@ -28,21 +36,21 @@ data class PlayStartedResponse(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayEndedRequest(
|
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("play_event_id") val playEventId: String,
|
||||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlaySkippedRequest(
|
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("play_event_id") val playEventId: String,
|
||||||
@SerialName("position_ms") val positionMs: Long,
|
@SerialName("position_ms") val positionMs: Long,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayOfflineRequest(
|
data class PlayOfflineRequest(
|
||||||
val type: String = "play_offline",
|
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_offline",
|
||||||
@SerialName("track_id") val trackId: String,
|
@SerialName("track_id") val trackId: String,
|
||||||
@SerialName("client_id") val clientId: String,
|
@SerialName("client_id") val clientId: String,
|
||||||
val at: String,
|
val at: String,
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import kotlinx.serialization.Serializable
|
|||||||
* `internal/api/types.go HomeIndexPayload`).
|
* `internal/api/types.go HomeIndexPayload`).
|
||||||
*
|
*
|
||||||
* Section name implies entity type; no per-entry type tag is needed:
|
* Section name implies entity type; no per-entry type tag is needed:
|
||||||
* - recentlyAddedAlbums → album
|
* - recentlyAddedAlbums → album
|
||||||
* - rediscoverAlbums → album
|
* - rediscoverAlbums → album
|
||||||
* - rediscoverArtists → artist
|
* - rediscoverArtists → artist
|
||||||
* - mostPlayedTracks → track
|
* - mostPlayedTracks → track
|
||||||
* - lastPlayedArtists → artist
|
* - 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
|
@Serializable
|
||||||
data class HomeIndexWire(
|
data class HomeIndexWire(
|
||||||
@@ -25,4 +29,6 @@ data class HomeIndexWire(
|
|||||||
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
||||||
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
||||||
@SerialName("last_played_artists") val lastPlayedArtists: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
-25
@@ -10,14 +10,19 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import androidx.media3.common.ForwardingPlayer
|
import androidx.media3.common.ForwardingPlayer
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
|
import java.io.IOException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
@@ -39,9 +44,15 @@ import timber.log.Timber
|
|||||||
* wrapped ExoPlayer stays paused at the position it had when the
|
* wrapped ExoPlayer stays paused at the position it had when the
|
||||||
* route was selected.
|
* route was selected.
|
||||||
*
|
*
|
||||||
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
|
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
|
||||||
* factory wraps that callback into a SharedFlow consumed by the
|
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
|
||||||
* NowPlaying surface as a snackbar.
|
* (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
|
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||||
@@ -54,6 +65,8 @@ class MinstrelForwardingPlayer(
|
|||||||
private val delegate: Player,
|
private val delegate: Player,
|
||||||
private val holder: ActiveUpnpHolder,
|
private val holder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
|
private val castNetworkLock: CastNetworkLock,
|
||||||
|
private val networkStatus: NetworkStatusController,
|
||||||
private val onDrop: (routeName: String) -> Unit,
|
private val onDrop: (routeName: String) -> Unit,
|
||||||
) : ForwardingPlayer(delegate) {
|
) : ForwardingPlayer(delegate) {
|
||||||
|
|
||||||
@@ -107,6 +120,20 @@ class MinstrelForwardingPlayer(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
holder.active.collect { active -> onActiveChanged(active) }
|
holder.active.collect { active -> onActiveChanged(active) }
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
// When the phone's own network recovers, reconcile a held cast
|
||||||
|
// session immediately instead of waiting up to POLL_INTERVAL_MS:
|
||||||
|
// the renderer kept playing, so one round-trip re-confirms it and
|
||||||
|
// resyncs the cursor/position. Only a transition *into* Healthy
|
||||||
|
// while a route is active matters.
|
||||||
|
var prev = networkStatus.state.value
|
||||||
|
networkStatus.state.collect { next ->
|
||||||
|
if (next == ServerHealth.Healthy && prev != ServerHealth.Healthy && isRemote()) {
|
||||||
|
pollTrigger.trySend(Unit)
|
||||||
|
}
|
||||||
|
prev = next
|
||||||
|
}
|
||||||
|
}
|
||||||
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
||||||
// addObserver requires it. The observer just trySend's to the channel.
|
// addObserver requires it. The observer just trySend's to the channel.
|
||||||
handler.post {
|
handler.post {
|
||||||
@@ -174,12 +201,12 @@ class MinstrelForwardingPlayer(
|
|||||||
} else {
|
} else {
|
||||||
remoteState.setPlayIntent(true)
|
remoteState.setPlayIntent(true)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.play() }
|
runCatching { retryTransport { active.avTransport.play() } }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
remoteState.applyTransportPlaying()
|
remoteState.applyTransportPlaying()
|
||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,12 +223,12 @@ class MinstrelForwardingPlayer(
|
|||||||
} else {
|
} else {
|
||||||
remoteState.setPlayIntent(false)
|
remoteState.setPlayIntent(false)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.pause() }
|
runCatching { retryTransport { active.avTransport.pause() } }
|
||||||
.onSuccess {
|
.onSuccess {
|
||||||
remoteState.applyTransportPaused()
|
remoteState.applyTransportPaused()
|
||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,8 +251,8 @@ class MinstrelForwardingPlayer(
|
|||||||
trackNumber = remoteState.trackNumber,
|
trackNumber = remoteState.trackNumber,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.seek(positionMs) }
|
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,11 +282,13 @@ class MinstrelForwardingPlayer(
|
|||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
retryTransport {
|
||||||
if (positionMs > 0L) {
|
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||||
active.avTransport.seek(positionMs)
|
if (positionMs > 0L) {
|
||||||
|
active.avTransport.seek(positionMs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { handleSoapFailure(active, it) }
|
}.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,8 +311,8 @@ class MinstrelForwardingPlayer(
|
|||||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.next() }
|
runCatching { retryTransport { active.avTransport.next() } }
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,8 +334,8 @@ class MinstrelForwardingPlayer(
|
|||||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||||
)
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { active.avTransport.previous() }
|
runCatching { retryTransport { active.avTransport.previous() } }
|
||||||
.onFailure { handleSoapFailure(active, it) }
|
.onFailure { handleTransportFailure(active, it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,11 +416,34 @@ class MinstrelForwardingPlayer(
|
|||||||
super.release()
|
super.release()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
|
/**
|
||||||
pollJob?.cancel()
|
* Run a transport SOAP block, retrying transport-level (IO) failures a
|
||||||
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
|
* few times with backoff. A locked phone's WiFi power-save can stall the
|
||||||
remoteState.applyError(t)
|
* first socket I/O for a second or two; a retry lets the command land once
|
||||||
handler.post { onDrop(active.routeName) }
|
* 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?) {
|
private fun onActiveChanged(active: ActiveUpnp?) {
|
||||||
@@ -404,6 +456,11 @@ class MinstrelForwardingPlayer(
|
|||||||
lastNotifiedTrackIdx = -1
|
lastNotifiedTrackIdx = -1
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||||
|
// Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
|
||||||
|
// about to be paused (releasing its WAKE_MODE_NETWORK locks), so
|
||||||
|
// without this the radio power-saves on a locked screen and the
|
||||||
|
// poll below starves -- see [CastNetworkLock].
|
||||||
|
castNetworkLock.acquire()
|
||||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||||
// simultaneously with the remote renderer. handler.post targets the
|
// simultaneously with the remote renderer. handler.post targets the
|
||||||
// application looper, so this runs on the same thread that processes
|
// application looper, so this runs on the same thread that processes
|
||||||
@@ -413,20 +470,26 @@ class MinstrelForwardingPlayer(
|
|||||||
handler.post { delegate.pause() }
|
handler.post { delegate.pause() }
|
||||||
pollJob = scope.launch { pollLoop(active) }
|
pollJob = scope.launch { pollLoop(active) }
|
||||||
} else {
|
} else {
|
||||||
|
castNetworkLock.release()
|
||||||
remoteState.reset()
|
remoteState.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||||
|
var networkDropSuppressed = false
|
||||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||||
val outcome = runCatching { pollOnce(active) }
|
val outcome = runCatching { pollOnce(active) }
|
||||||
if (outcome.isSuccess) {
|
if (outcome.isSuccess) {
|
||||||
remoteState.recordPollSuccess()
|
remoteState.recordPollSuccess()
|
||||||
|
networkDropSuppressed = false
|
||||||
} else if (remoteState.recordPollFailure()) {
|
} else if (remoteState.recordPollFailure()) {
|
||||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
if (networkStatus.state.value == ServerHealth.Healthy) {
|
||||||
handler.post { onDrop(active.routeName) }
|
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||||
return
|
handler.post { onDrop(active.routeName) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
|
||||||
}
|
}
|
||||||
// Race the normal cadence against any external wake (activity
|
// Race the normal cadence against any external wake (activity
|
||||||
// resume). Whichever wins continues to the next pollOnce.
|
// resume). Whichever wins continues to the next pollOnce.
|
||||||
@@ -437,6 +500,30 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The poll-failure threshold tripped, but the phone's *own* network is down
|
||||||
|
* (state is not [ServerHealth.Healthy]) -- so this is "we can't see the
|
||||||
|
* renderer right now," not "the renderer died." A UPnP renderer streams
|
||||||
|
* autonomously and is almost certainly still playing; meanwhile a local
|
||||||
|
* fallback couldn't reach the server either, so dropping would only blast
|
||||||
|
* local audio out of a pocketed phone while the speaker keeps going. Hold
|
||||||
|
* the route and keep polling -- a successful poll once the network returns
|
||||||
|
* reconciles to the renderer's real state. Clear the failure streak so
|
||||||
|
* recovery re-evaluates the renderer from scratch instead of re-dropping on
|
||||||
|
* the first post-recovery hiccup. Returns the (latched) suppression flag so
|
||||||
|
* the rationale logs once per outage, not every tick.
|
||||||
|
*/
|
||||||
|
private fun suppressDropForNetwork(active: ActiveUpnp, alreadySuppressed: Boolean): Boolean {
|
||||||
|
if (!alreadySuppressed) {
|
||||||
|
Timber.w(
|
||||||
|
"UPnP drop suppressed for %s -- phone network %s, holding route",
|
||||||
|
active.routeName, networkStatus.state.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
remoteState.recordPollSuccess() // clear streak; not a renderer failure
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One poll tick: read position + transport state from Sonos, apply to
|
* One poll tick: read position + transport state from Sonos, apply to
|
||||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||||
@@ -515,5 +602,39 @@ class MinstrelForwardingPlayer(
|
|||||||
// when Sonos's reported Track matches the wrapped player; this only
|
// when Sonos's reported Track matches the wrapped player; this only
|
||||||
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
||||||
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
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.cache.mutations.PlayOfflinePayload
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||||
@@ -64,11 +65,24 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
private var curLastPositionMs: Long = 0
|
private var curLastPositionMs: Long = 0
|
||||||
private var curDurationMs: 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;
|
// Server play_event_id when the live play_started succeeded;
|
||||||
// null if start failed/offline — then the close is captured into
|
// null if start failed/offline — then the close is captured into
|
||||||
// the offline mutation queue instead of a live ended/skipped.
|
// the offline mutation queue instead of a live ended/skipped.
|
||||||
private var openPlayEventId: String? = null
|
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
|
private var prevTrackId: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -95,7 +109,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
// Track-change → close the prior tracked play.
|
// Track-change → close the prior tracked play.
|
||||||
if (trackId != prevTrackId && curTrackId != null) {
|
if (trackId != prevTrackId && curTrackId != null) {
|
||||||
closeCurrent(viaOffline = false)
|
closeCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Entered playing for a new track → begin tracking it.
|
// Entered playing for a new track → begin tracking it.
|
||||||
@@ -110,6 +124,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
if (curTrackId != null && trackId == curTrackId) {
|
if (curTrackId != null && trackId == curTrackId) {
|
||||||
curLastPositionMs = positionMs
|
curLastPositionMs = positionMs
|
||||||
if (durationMs > 0) curDurationMs = durationMs
|
if (durationMs > 0) curDurationMs = durationMs
|
||||||
|
curPlaying = playing
|
||||||
}
|
}
|
||||||
|
|
||||||
prevTrackId = trackId
|
prevTrackId = trackId
|
||||||
@@ -121,12 +136,18 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
curSource = source
|
curSource = source
|
||||||
curLastPositionMs = 0
|
curLastPositionMs = 0
|
||||||
curDurationMs = durationMs
|
curDurationMs = durationMs
|
||||||
|
curPlaying = true
|
||||||
openPlayEventId = null
|
openPlayEventId = null
|
||||||
|
startInFlight = true
|
||||||
startLive(trackId = trackId, source = source)
|
startLive(trackId = trackId, source = source)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startLive(trackId: String, source: String?) {
|
private fun startLive(trackId: String, source: String?) {
|
||||||
val cid = resolveClientId() ?: return
|
val cid = resolveClientId()
|
||||||
|
if (cid == null) {
|
||||||
|
startInFlight = false
|
||||||
|
return
|
||||||
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
val response = api.playStarted(
|
val response = api.playStarted(
|
||||||
@@ -144,56 +165,72 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
) {
|
) {
|
||||||
// Offline / flaky — openPlayEventId stays null; close
|
// Offline / flaky — openPlayEventId stays null; close
|
||||||
// path will enqueue the completed play for replay.
|
// 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 trackId = curTrackId
|
||||||
val startedAt = curStartedAt
|
val startedAt = curStartedAt
|
||||||
if (trackId == null || startedAt == null) {
|
if (trackId == null || startedAt == null) {
|
||||||
resetCurrent()
|
resetCurrent()
|
||||||
return
|
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 durationMs = curLastPositionMs
|
||||||
val source = curSource
|
val source = curSource
|
||||||
val id = openPlayEventId
|
val id = openPlayEventId
|
||||||
|
val inFlight = startInFlight
|
||||||
if (!viaOffline && id != null) {
|
when {
|
||||||
scope.launch {
|
id != null -> closeById(id, durationMs)
|
||||||
try {
|
inFlight -> Unit
|
||||||
api.playEnded(
|
else -> enqueueOffline(trackId, startedAt, source, durationMs)
|
||||||
PlayEndedRequest(
|
|
||||||
playEventId = id,
|
|
||||||
durationPlayedMs = durationMs,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} catch (
|
|
||||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
|
||||||
) {
|
|
||||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
|
||||||
}
|
}
|
||||||
resetCurrent()
|
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() {
|
private fun resetCurrent() {
|
||||||
curTrackId = null
|
curTrackId = null
|
||||||
curStartedAt = null
|
curStartedAt = null
|
||||||
curSource = null
|
curSource = null
|
||||||
curLastPositionMs = 0
|
curLastPositionMs = 0
|
||||||
curDurationMs = 0
|
curDurationMs = 0
|
||||||
|
curPlaying = false
|
||||||
openPlayEventId = null
|
openPlayEventId = null
|
||||||
|
startInFlight = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun enqueueOffline(
|
private fun enqueueOffline(
|
||||||
@@ -232,8 +269,17 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
// ── Lifecycle: durable-close on app background / detach ────────
|
// ── Lifecycle: durable-close on app background / detach ────────
|
||||||
|
|
||||||
override fun onStop(owner: LifecycleOwner) {
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
if (curTrackId != null) {
|
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||||
closeCurrent(viaOffline = true)
|
// 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
|||||||
import androidx.media3.session.SessionToken
|
import androidx.media3.session.SessionToken
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||||
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -63,6 +65,7 @@ class PlayerController @Inject constructor(
|
|||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
@ApplicationScope private val scope: CoroutineScope,
|
@ApplicationScope private val scope: CoroutineScope,
|
||||||
private val radio: RadioController,
|
private val radio: RadioController,
|
||||||
|
private val playlists: PlaylistsRepository,
|
||||||
private val playerFactory: PlayerFactory,
|
private val playerFactory: PlayerFactory,
|
||||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
@@ -111,6 +114,13 @@ class PlayerController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private var lastEvaluatedItemIndex: Int = -1
|
private var lastEvaluatedItemIndex: Int = -1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #968: self-heal budget — re-pulls of a stale refreshable queue since
|
||||||
|
* the last successful play. Capped so a still-broken refresh can't loop;
|
||||||
|
* reset when any track loads with real audio.
|
||||||
|
*/
|
||||||
|
private var selfHealAttempts = 0
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stable queue snapshot kept in sync with the player's MediaItems —
|
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||||
* the player's own getMediaItem(index) returns Media3 types; we keep
|
* the player's own getMediaItem(index) returns Media3 types; we keep
|
||||||
@@ -192,6 +202,20 @@ class PlayerController @Inject constructor(
|
|||||||
controller.play()
|
controller.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Align the local cursor to [index] + [positionMs] WITHOUT auto-playing.
|
||||||
|
* Used by the UPnP adopt path: when attaching to a renderer that's already
|
||||||
|
* playing our queue, we sync the local cursor to the renderer's current
|
||||||
|
* track/position so skip/seek and the UI map onto its live session — the
|
||||||
|
* renderer keeps the audio, the local player stays paused. Unlike
|
||||||
|
* [seekToIndex] this issues no play().
|
||||||
|
*/
|
||||||
|
fun moveCursorTo(index: Int, positionMs: Long) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (index !in queueRefs.indices) return
|
||||||
|
runOnControllerThread(controller) { controller.seekTo(index, positionMs) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the queue with [tracks] starting at [initialIndex].
|
* Replace the queue with [tracks] starting at [initialIndex].
|
||||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
* [source] tags the queue with its origin (e.g. "for_you") so the
|
||||||
@@ -337,7 +361,12 @@ class PlayerController @Inject constructor(
|
|||||||
if (current == null || upnpEngaged) return
|
if (current == null || upnpEngaged) return
|
||||||
val duration = controller.duration
|
val duration = controller.duration
|
||||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||||
if (!isZeroDuration) return
|
if (!isZeroDuration) {
|
||||||
|
// A track loaded with real audio — clear the self-heal budget so
|
||||||
|
// a later stale queue can recover again.
|
||||||
|
selfHealAttempts = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
playbackErrorEventsChannel.trySend(
|
playbackErrorEventsChannel.trySend(
|
||||||
PlaybackErrorEvent(
|
PlaybackErrorEvent(
|
||||||
trackId = current.id,
|
trackId = current.id,
|
||||||
@@ -353,6 +382,58 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #968: the queue is fully unplayable (every track failed to load). If it
|
||||||
|
* came from a refreshable source — a system playlist (bare-variant source)
|
||||||
|
* or radio ("radio:<seed>") — the snapshot is probably stale (app/tab left
|
||||||
|
* open across the daily rebuild); re-pull it and resume instead of stopping.
|
||||||
|
* Bounded by [selfHealAttempts]. Album / artist / user-playlist / offline
|
||||||
|
* sources have nothing to refresh — stop.
|
||||||
|
*/
|
||||||
|
private fun selfHealOrStop(controller: MediaController) {
|
||||||
|
val source = controller.currentMediaItem
|
||||||
|
?.mediaMetadata?.extras?.getString(MINSTREL_SOURCE_KEY)
|
||||||
|
if (source == null || selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) {
|
||||||
|
controller.stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when {
|
||||||
|
source.startsWith("radio:") -> {
|
||||||
|
selfHealAttempts++
|
||||||
|
scope.launch { selfHealRadio(source.removePrefix("radio:")) }
|
||||||
|
}
|
||||||
|
!source.contains(':') -> { // bare variant = refreshable system playlist
|
||||||
|
selfHealAttempts++
|
||||||
|
scope.launch { selfHealSystem(source) }
|
||||||
|
}
|
||||||
|
else -> controller.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun selfHealSystem(variant: String) {
|
||||||
|
val refs = runCatching { playlists.systemShuffle(variant).tracks.toPlayableTrackRefs() }
|
||||||
|
.getOrDefault(emptyList())
|
||||||
|
if (refs.isEmpty()) {
|
||||||
|
stopOnControllerThread()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQueue(refs, initialIndex = 0, source = variant)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun selfHealRadio(seedTrackId: String) {
|
||||||
|
val refs = runCatching { radio.seed(seedTrackId) }.getOrDefault(emptyList())
|
||||||
|
if (refs.isEmpty()) {
|
||||||
|
stopOnControllerThread()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setQueue(refs, initialIndex = 0, source = "radio:$seedTrackId")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopOnControllerThread() {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
runOnControllerThread(controller) { controller.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||||
|
|
||||||
private suspend fun connectAndObserve() {
|
private suspend fun connectAndObserve() {
|
||||||
@@ -403,6 +484,17 @@ class PlayerController @Inject constructor(
|
|||||||
detail = error.message,
|
detail = error.message,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
// A failed load leaves the player IDLE; advance past the bad
|
||||||
|
// track and re-prepare so one unplayable item doesn't strand
|
||||||
|
// playback (mirrors the zero_duration skip). At the end of a
|
||||||
|
// fully-unplayable queue, try to self-heal a stale refreshable
|
||||||
|
// source before stopping.
|
||||||
|
if (controller.hasNextMediaItem()) {
|
||||||
|
controller.seekToNextMediaItem()
|
||||||
|
controller.prepare()
|
||||||
|
} else {
|
||||||
|
selfHealOrStop(controller)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMediaItemTransition(
|
override fun onMediaItemTransition(
|
||||||
@@ -749,6 +841,10 @@ class PlayerController @Inject constructor(
|
|||||||
// they don't need this poll.
|
// they don't need this poll.
|
||||||
private const val POSITION_POLL_INTERVAL_MS = 500L
|
private const val POSITION_POLL_INTERVAL_MS = 500L
|
||||||
|
|
||||||
|
// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
|
||||||
|
// the next successful play) so a still-broken refresh can't loop.
|
||||||
|
private const val MAX_SELF_HEAL_ATTEMPTS = 1
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ class PlayerFactory @Inject constructor(
|
|||||||
delegate = exo,
|
delegate = exo,
|
||||||
holder = activeUpnpHolder,
|
holder = activeUpnpHolder,
|
||||||
remoteState = remoteState,
|
remoteState = remoteState,
|
||||||
|
castNetworkLock = CastNetworkLock(context),
|
||||||
|
networkStatus = serverHealth,
|
||||||
onDrop = { name -> emitDrop(name) },
|
onDrop = { name -> emitDrop(name) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import javax.inject.Singleton
|
|||||||
* Updates flow in from:
|
* Updates flow in from:
|
||||||
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
||||||
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
* - 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
|
* 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
|
@Singleton
|
||||||
class RemotePlayerState @Inject constructor() {
|
class RemotePlayerState @Inject constructor() {
|
||||||
|
|||||||
+135
-4
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -162,7 +164,8 @@ class OutputPickerController @Inject constructor(
|
|||||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||||
mediaRouter.addCallback(selector, callback)
|
mediaRouter.addCallback(selector, callback)
|
||||||
// When MinstrelForwardingPlayer reports the active UPnP route has
|
// 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
|
// clear the UPnP selection state and fall back to local ExoPlayer at
|
||||||
// the last-known remote position. This mirrors selectSystem's disconnect
|
// the last-known remote position. This mirrors selectSystem's disconnect
|
||||||
// path but skips the Stop SOAP since the device is already unreachable.
|
// path but skips the Stop SOAP since the device is already unreachable.
|
||||||
@@ -171,6 +174,31 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
scope.launch { observeQueueChangesForSonosResync() }
|
scope.launch { observeQueueChangesForSonosResync() }
|
||||||
scope.launch { observeIdleRevertWhileUpnp() }
|
scope.launch { observeIdleRevertWhileUpnp() }
|
||||||
|
scope.launch { observeSelectedRouteDisappearance() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert the UPnP selection to the phone when the selected renderer leaves
|
||||||
|
* discovery (powered off / off the LAN) while we are NOT actively casting
|
||||||
|
* to it. Without this the selection could pin to a ghost route, so a later
|
||||||
|
* "play" would target a speaker that's gone. An *active* cast is left alone
|
||||||
|
* -- its own poll loop arbitrates liveness and won't drop on a mere
|
||||||
|
* discovery gap. Routes leave discovery via [UpnpDiscoveryController]'s
|
||||||
|
* post-picker-scan prune; this reacts to the resulting routes change.
|
||||||
|
*/
|
||||||
|
private suspend fun observeSelectedRouteDisappearance() {
|
||||||
|
upnpDiscovery.routes.collect { routes ->
|
||||||
|
val selected = selectedUpnpRouteIdInternal.value
|
||||||
|
val present = routes.any { it.id == selected }
|
||||||
|
val casting = activeUpnpHolder.active.value?.routeId == selected
|
||||||
|
if (selected != null && !present && !casting) {
|
||||||
|
Timber.w(
|
||||||
|
"Output: selected UPnP route %s left discovery; reverting to phone",
|
||||||
|
selected,
|
||||||
|
)
|
||||||
|
selectedUpnpRouteIdInternal.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -420,9 +448,9 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when the active UPnP route drops unexpectedly (poll-failure
|
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
||||||
* threshold or SOAP exception). Captures the last remote position +
|
* consecutive-failure threshold tripping). Captures the last remote
|
||||||
* play state, clears UPnP selection, and resumes local ExoPlayer at
|
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||||
* the same point. Skips the Stop SOAP (device already unreachable).
|
* the same point. Skips the Stop SOAP (device already unreachable).
|
||||||
* The snackbar is handled independently by the NowPlaying surface
|
* The snackbar is handled independently by the NowPlaying surface
|
||||||
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
||||||
@@ -540,6 +568,13 @@ class OutputPickerController @Inject constructor(
|
|||||||
// local audio while we queue up Sonos.
|
// local audio while we queue up Sonos.
|
||||||
playerController.pause()
|
playerController.pause()
|
||||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||||
|
// If the renderer is already playing this exact queue (a "stray"
|
||||||
|
// session we got disconnected from), attach to it in place instead of
|
||||||
|
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
||||||
|
// live queue. Falls through to the clear+reload path when it isn't ours.
|
||||||
|
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||||
// silently while target is set but active is null -- the user's premature
|
// silently while target is set but active is null -- the user's premature
|
||||||
// taps don't hit Sonos's stale state from a prior session.
|
// taps don't hit Sonos's stale state from a prior session.
|
||||||
@@ -566,6 +601,102 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The renderer's current position + transport state, validated as an
|
||||||
|
* adoptable continuation of our local queue: same track id at the same
|
||||||
|
* 1-based index. Null means "not ours / not playing" -> caller reloads.
|
||||||
|
*/
|
||||||
|
private data class AdoptPlan(
|
||||||
|
val localIndex: Int,
|
||||||
|
val positionMs: Long,
|
||||||
|
val durationMs: Long,
|
||||||
|
val trackUri: String,
|
||||||
|
val trackNumber: Int,
|
||||||
|
val playing: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach to a renderer that is *already* playing our queue (a session the
|
||||||
|
* phone got disconnected from but the speaker kept streaming) instead of
|
||||||
|
* clearing + reloading it. Probes the renderer's live state, and if it's
|
||||||
|
* mid-playback on the same track id at the same queue index, syncs the
|
||||||
|
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
|
||||||
|
* skips a beat, and skip/seek now drive its live queue. Returns false (and
|
||||||
|
* the caller does the full clear+reload) when the renderer isn't playing
|
||||||
|
* our queue. The local player is already paused by the caller.
|
||||||
|
*/
|
||||||
|
private suspend fun tryAdoptRunningSession(
|
||||||
|
transport: AVTransportClient,
|
||||||
|
rendering: RenderingControlClient?,
|
||||||
|
route: OutputRoute,
|
||||||
|
localQueue: List<TrackRef>,
|
||||||
|
): Boolean {
|
||||||
|
val plan = probeAdoptable(transport, route, localQueue) ?: return false
|
||||||
|
Timber.w(
|
||||||
|
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
||||||
|
route.name, plan.trackNumber, plan.positionMs,
|
||||||
|
)
|
||||||
|
// active is still null here, so this aligns the *local* cursor without
|
||||||
|
// emitting SOAP; the holder.set below then starts the poll loop, which
|
||||||
|
// reconciles to the renderer's advancing position from here.
|
||||||
|
playerController.moveCursorTo(plan.localIndex, plan.positionMs)
|
||||||
|
remoteState.applyPositionInfo(
|
||||||
|
positionMs = plan.positionMs,
|
||||||
|
durationMs = plan.durationMs,
|
||||||
|
trackUri = plan.trackUri,
|
||||||
|
trackNumber = plan.trackNumber,
|
||||||
|
)
|
||||||
|
remoteState.setPlayIntent(plan.playing)
|
||||||
|
activeUpnpHolder.set(
|
||||||
|
ActiveUpnp(
|
||||||
|
routeId = route.id,
|
||||||
|
routeName = route.name,
|
||||||
|
avTransport = transport,
|
||||||
|
rendering = rendering,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun probeAdoptable(
|
||||||
|
transport: AVTransportClient,
|
||||||
|
route: OutputRoute,
|
||||||
|
localQueue: List<TrackRef>,
|
||||||
|
): AdoptPlan? {
|
||||||
|
val probe = runCatching {
|
||||||
|
transport.getTransportInfo() to transport.getPositionInfo()
|
||||||
|
}.getOrElse {
|
||||||
|
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val (info, pos) = probe
|
||||||
|
val idx0 = pos.track - 1
|
||||||
|
val playingId = trackIdFromStreamUrl(pos.trackUri)
|
||||||
|
val midPlayback =
|
||||||
|
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
||||||
|
val aligned = midPlayback && playingId != null &&
|
||||||
|
idx0 in localQueue.indices && localQueue[idx0].id == playingId
|
||||||
|
return if (aligned) {
|
||||||
|
AdoptPlan(
|
||||||
|
localIndex = idx0,
|
||||||
|
positionMs = pos.relTimeMs,
|
||||||
|
durationMs = pos.trackDurationMs,
|
||||||
|
trackUri = pos.trackUri,
|
||||||
|
trackNumber = pos.track,
|
||||||
|
playing = info.state == TransportState.PLAYING,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
|
||||||
|
private fun trackIdFromStreamUrl(url: String): String? {
|
||||||
|
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
|
||||||
|
val i = segments.indexOf("tracks")
|
||||||
|
return if (i >= 0) segments.getOrNull(i + 1) else null
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadQueueOnSonos(
|
private suspend fun loadQueueOnSonos(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
|
|||||||
+39
-1
@@ -8,6 +8,7 @@ import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -17,6 +18,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -58,6 +60,12 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||||
|
|
||||||
|
// Wall-clock of the last NOTIFY / M-SEARCH response per route id. Drives
|
||||||
|
// post-active-scan pruning so a powered-off renderer leaves the picker
|
||||||
|
// (and can't pin a stale selection) instead of lingering for the process
|
||||||
|
// lifetime -- upsert only ever added before.
|
||||||
|
private val lastSeenMs = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||||
|
|
||||||
@@ -78,7 +86,31 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
* that just opened).
|
* that just opened).
|
||||||
*/
|
*/
|
||||||
fun upgradeDiscovery() {
|
fun upgradeDiscovery() {
|
||||||
appScope.launch { ssdp.requestActiveScan() }
|
appScope.launch {
|
||||||
|
val scanStartMs = System.currentTimeMillis()
|
||||||
|
ssdp.requestActiveScan()
|
||||||
|
// Give present devices time to answer the M-SEARCH (+ our follow-up
|
||||||
|
// description fetch), then drop any route that didn't re-announce.
|
||||||
|
// Pruning is tied to picker-open scans only -- no background timer,
|
||||||
|
// so route rows never flicker while the user isn't looking.
|
||||||
|
delay(ACTIVE_SCAN_PRUNE_DELAY_MS)
|
||||||
|
pruneRoutesNotSeenSince(scanStartMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop routes whose last NOTIFY / M-SEARCH response predates [cutoffMs]
|
||||||
|
* -- i.e. they didn't answer the active scan that just ran, so they're
|
||||||
|
* gone from the LAN. Called only after [upgradeDiscovery]'s scan window.
|
||||||
|
*/
|
||||||
|
private fun pruneRoutesNotSeenSince(cutoffMs: Long) {
|
||||||
|
val before = routesInternal.value
|
||||||
|
val survivors = before.filter { (lastSeenMs[it.id] ?: 0L) >= cutoffMs }
|
||||||
|
if (survivors.size == before.size) return
|
||||||
|
val removed = before.filterNot { it in survivors }.map { it.id }
|
||||||
|
removed.forEach { lastSeenMs.remove(it) }
|
||||||
|
Timber.w("UPnP discovery: pruned %d stale route(s): %s", removed.size, removed)
|
||||||
|
routesInternal.value = survivors
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,6 +147,7 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun upsertRoute(route: UpnpRoute) {
|
private fun upsertRoute(route: UpnpRoute) {
|
||||||
|
lastSeenMs[route.id] = System.currentTimeMillis()
|
||||||
val current = routesInternal.value
|
val current = routesInternal.value
|
||||||
val idx = current.indexOfFirst { it.id == route.id }
|
val idx = current.indexOfFirst { it.id == route.id }
|
||||||
routesInternal.value = if (idx < 0) {
|
routesInternal.value = if (idx < 0) {
|
||||||
@@ -240,6 +273,11 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
||||||
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
||||||
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
||||||
|
|
||||||
|
// Grace window after an M-SEARCH burst before pruning non-responders.
|
||||||
|
// Covers the SSDP round-trip plus our per-device description fetch;
|
||||||
|
// generous enough that a present-but-slow renderer isn't false-pruned.
|
||||||
|
const val ACTIVE_SCAN_PRUNE_DELAY_MS = 4_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+68
@@ -28,6 +28,10 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -139,6 +143,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
||||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #980: a system-playlist rebuild landed (SSE) while this screen is open —
|
||||||
|
* its list is now stale and the uuid has rotated. Drives a "this mix was
|
||||||
|
* refreshed → Refresh" snackbar rather than yanking the user; Refresh
|
||||||
|
* re-resolves the variant via [reloadRebuilt].
|
||||||
|
*/
|
||||||
|
private val staleInternal = MutableStateFlow(false)
|
||||||
|
val stale: StateFlow<Boolean> = staleInternal.asStateFlow()
|
||||||
|
|
||||||
val likedTrackIds: StateFlow<Set<String>> =
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
likes.observeLikedTrackIds()
|
likes.observeLikedTrackIds()
|
||||||
.stateIn(
|
.stateIn(
|
||||||
@@ -163,6 +176,12 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
* ignored — the playlists-list screen handles those.
|
* ignored — the playlists-list screen handles those.
|
||||||
*/
|
*/
|
||||||
private fun handlePlaylistEvent(event: LiveEvent) {
|
private fun handlePlaylistEvent(event: LiveEvent) {
|
||||||
|
// #980: a system rebuild carries no playlist_id (it rebuilds all of the
|
||||||
|
// user's system mixes at once) — handle it before the per-id filter.
|
||||||
|
if (event.kind == "playlist.system_rebuilt") {
|
||||||
|
markStaleIfSystem()
|
||||||
|
return
|
||||||
|
}
|
||||||
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
||||||
if (eventPlaylistId != playlistId) return
|
if (eventPlaylistId != playlistId) return
|
||||||
when (event.kind) {
|
when (event.kind) {
|
||||||
@@ -171,6 +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) {
|
fun toggleLikeTrack(trackId: String) {
|
||||||
val desired = trackId !in likedTrackIds.value
|
val desired = trackId !in likedTrackIds.value
|
||||||
viewModelScope.launch {
|
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). */
|
/** Play the available tracks starting at [startTrackId] (or first available). */
|
||||||
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
||||||
val refs = tracks.toPlayableTrackRefs()
|
val refs = tracks.toPlayableTrackRefs()
|
||||||
@@ -261,8 +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(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(currentTitle(state)) },
|
title = { Text(currentTitle(state)) },
|
||||||
|
|||||||
@@ -1,15 +1,25 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||||
* is blank or unresolvable.
|
* is blank/unresolvable, while the image is still loading, and when the
|
||||||
|
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||||
|
* which the "You might like" row hits often).
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerImage(
|
fun ServerImage(
|
||||||
@@ -22,12 +32,26 @@ fun ServerImage(
|
|||||||
val resolved = resolveServerUrl(url)
|
val resolved = resolveServerUrl(url)
|
||||||
if (resolved == null) {
|
if (resolved == null) {
|
||||||
fallback()
|
fallback()
|
||||||
} else {
|
return
|
||||||
|
}
|
||||||
|
// Track Coil's load state so the fallback doubles as a placeholder
|
||||||
|
// (loading) and an error state (404 / unreachable) — not just a
|
||||||
|
// null-URL guard, which left present-but-failing URLs blank.
|
||||||
|
var state by remember(resolved) {
|
||||||
|
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
||||||
|
}
|
||||||
|
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = resolved,
|
model = resolved,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
|
onState = { state = it },
|
||||||
)
|
)
|
||||||
|
if (state is AsyncImagePainter.State.Loading ||
|
||||||
|
state is AsyncImagePainter.State.Error
|
||||||
|
) {
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-8
@@ -6,17 +6,27 @@ in-app update flow (#397).
|
|||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|
||||||
CI populates this directory on tag releases:
|
CI populates this directory in `release.yml` (no Flutter — the client is
|
||||||
1. `flutter.yml` builds `app-release.apk` and attaches it to the
|
native Android, built by the `android-release` job):
|
||||||
Gitea release as `minstrel-<TAG>.apk`.
|
|
||||||
2. `release.yml` waits for that asset to appear, downloads it into
|
- **Tag release (`v*`):** the `android-release` job builds + signs the
|
||||||
this directory as `minstrel.apk`, writes the tag string to
|
APK, uploads it as a workflow artifact, and attaches it to the Gitea
|
||||||
`minstrel.apk.version`, and `docker buildx build` includes both
|
release as `minstrel-<TAG>.apk`. The `image-release` job
|
||||||
via `COPY client/ /app/client/`.
|
(`needs: android-release`) downloads that artifact into this directory
|
||||||
|
as `minstrel.apk` and writes the computed versionName to
|
||||||
|
`minstrel.apk.version`.
|
||||||
|
- **Main push (non-tag `:latest` / `:main`):** no APK is built, but the
|
||||||
|
image still moves `:latest`, so `image-release` pulls the most-recent
|
||||||
|
release's signed APK from the Gitea API and reconstructs its exact
|
||||||
|
versionName — keeping the in-app update channel alive on `:latest`
|
||||||
|
instead of stripping it until the next tag.
|
||||||
|
|
||||||
|
Either way, `docker buildx build` bakes both files into the image via
|
||||||
|
`COPY client/ /app/client/`.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Empty directory works fine — the endpoints return 404 and the Flutter
|
Empty directory works fine — the endpoints return 404 and the Android
|
||||||
update banner stays hidden ("no update channel" graceful degradation).
|
update banner stays hidden ("no update channel" graceful degradation).
|
||||||
|
|
||||||
To smoke-test the update flow locally, drop a real APK + a version
|
To smoke-test the update flow locally, drop a real APK + a version
|
||||||
|
|||||||
@@ -242,7 +242,12 @@ func run() error {
|
|||||||
// active user's daily build at 03:00 in their stored timezone.
|
// active user's daily build at 03:00 in their stored timezone.
|
||||||
// Replaces the 24h-anchored cron loop (removed in the next commit
|
// Replaces the 24h-anchored cron loop (removed in the next commit
|
||||||
// of this arc).
|
// of this arc).
|
||||||
playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir)
|
playlistScheduler, err := playlists.NewScheduler(
|
||||||
|
pool,
|
||||||
|
logger.With("component", "playlist_scheduler"),
|
||||||
|
cfg.Storage.DataDir,
|
||||||
|
bus,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("init playlist scheduler: %w", err)
|
return fmt.Errorf("init playlist scheduler: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -26,8 +26,13 @@ library:
|
|||||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||||
scan_paths: []
|
scan_paths: []
|
||||||
# Kick off a scan on server startup. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
|
# Kick off a scan on server startup. On by default so a fresh install
|
||||||
scan_on_startup: false
|
# populates its library on first boot with no manual step. Scans are
|
||||||
|
# incremental (unchanged files are skipped by mtime), so the cost on
|
||||||
|
# every later restart is just a directory walk, not a full re-index.
|
||||||
|
# Set false if you'd rather scan only on a schedule / via the admin UI.
|
||||||
|
# Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
|
||||||
|
scan_on_startup: true
|
||||||
|
|
||||||
# Per-instance branding. Operators can rename their instance ("Family
|
# Per-instance branding. Operators can rename their instance ("Family
|
||||||
# Jukebox", "Office Music", etc.) and override the OG share-preview
|
# Jukebox", "Office Music", etc.) and override the OG share-preview
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# README screenshots
|
||||||
|
|
||||||
|
Drop the files below into this directory with these **exact names** — the
|
||||||
|
root `README.md` already references them, so no Markdown editing is needed.
|
||||||
|
Export as PNG. Capture while logged in as admin and *after* the first
|
||||||
|
library scan so nothing looks empty; keep the same browser width and theme
|
||||||
|
across all shots for a consistent look.
|
||||||
|
|
||||||
|
| Filename | Route | What to capture |
|
||||||
|
|---|---|---|
|
||||||
|
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
|
||||||
|
| `register.png` | `/register` | The account-creation form on a fresh instance |
|
||||||
|
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
|
||||||
|
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
|
||||||
|
| `android-download.png` | `/settings` | The "Install the Android app" download card |
|
||||||
|
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
|
||||||
|
|
||||||
|
Tip: to control display width in the README, you can swap the Markdown
|
||||||
|
`` for an HTML tag, e.g. `<img src="docs/screenshots/home.png" width="800" alt="...">`.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -74,6 +74,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Post("/auth/logout", h.handleLogout)
|
authed.Post("/auth/logout", h.handleLogout)
|
||||||
authed.Get("/me", h.handleGetMe)
|
authed.Get("/me", h.handleGetMe)
|
||||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
||||||
|
authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||||
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
||||||
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
||||||
authed.Get("/me/history", h.handleGetMyHistory)
|
authed.Get("/me/history", h.handleGetMyHistory)
|
||||||
|
|||||||
@@ -88,6 +88,21 @@ func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// publishSystemRebuilt notifies the owner's clients that their system
|
||||||
|
// playlists (and You-might-like rows) were regenerated, so they invalidate
|
||||||
|
// the home / system-playlist providers and a stale active queue can re-pull.
|
||||||
|
// Mirrors the daily scheduler's event; fired here from the manual refresh.
|
||||||
|
func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
|
||||||
|
if h.eventbus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.eventbus.Publish(eventbus.Event{
|
||||||
|
Kind: "playlist.system_rebuilt",
|
||||||
|
UserID: uuidToString(userID),
|
||||||
|
Data: map[string]any{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
|
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
|
||||||
// the request's original requester so their /requests page reflects the
|
// the request's original requester so their /requests page reflects the
|
||||||
// new state without manual refresh. Admin actors (approve / reject) still
|
// new state without manual refresh. Admin actors (approve / reject) still
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
|
|||||||
RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)),
|
RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)),
|
||||||
MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)),
|
MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)),
|
||||||
LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)),
|
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 {
|
for _, row := range data.RecentlyAddedAlbums {
|
||||||
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
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 {
|
for _, row := range data.LastPlayedArtists {
|
||||||
out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
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)
|
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)),
|
RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)),
|
||||||
MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)),
|
MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)),
|
||||||
LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)),
|
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 {
|
for _, row := range data.RecentlyAddedAlbums {
|
||||||
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID))
|
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 {
|
for _, row := range data.LastPlayedArtists {
|
||||||
out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID))
|
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)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
writeErr(w, apierror.InternalMsg("build failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// #968: announce the rebuild so the user's other clients refresh.
|
||||||
|
h.publishSystemRebuilt(user.ID)
|
||||||
q := dbq.New(h.pool)
|
q := dbq.New(h.pool)
|
||||||
v := kind
|
v := kind
|
||||||
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
JitterMagnitude: h.recCfg.JitterMagnitude,
|
JitterMagnitude: h.recCfg.JitterMagnitude,
|
||||||
ContextWeight: h.recCfg.ContextWeight,
|
ContextWeight: h.recCfg.ContextWeight,
|
||||||
SimilarityWeight: h.recCfg.SimilarityWeight,
|
SimilarityWeight: h.recCfg.SimilarityWeight,
|
||||||
|
TasteWeight: h.recCfg.TasteWeight,
|
||||||
}
|
}
|
||||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,11 @@ type HomePayload struct {
|
|||||||
RediscoverArtists []ArtistRef `json:"rediscover_artists"`
|
RediscoverArtists []ArtistRef `json:"rediscover_artists"`
|
||||||
MostPlayedTracks []TrackRef `json:"most_played_tracks"`
|
MostPlayedTracks []TrackRef `json:"most_played_tracks"`
|
||||||
LastPlayedArtists []ArtistRef `json:"last_played_artists"`
|
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
|
// HomeIndexPayload is the response body of GET /api/home/index — the
|
||||||
@@ -134,6 +139,8 @@ type HomeIndexPayload struct {
|
|||||||
RediscoverArtists []string `json:"rediscover_artists"`
|
RediscoverArtists []string `json:"rediscover_artists"`
|
||||||
MostPlayedTracks []string `json:"most_played_tracks"`
|
MostPlayedTracks []string `json:"most_played_tracks"`
|
||||||
LastPlayedArtists []string `json:"last_played_artists"`
|
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
|
// HistoryEvent is one play in a user's listening history. Used by
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ type RecommendationConfig struct {
|
|||||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
||||||
ContextWeight float64 `yaml:"context_weight"`
|
ContextWeight float64 `yaml:"context_weight"`
|
||||||
SimilarityWeight float64 `yaml:"similarity_weight"`
|
SimilarityWeight float64 `yaml:"similarity_weight"`
|
||||||
|
TasteWeight float64 `yaml:"taste_weight"`
|
||||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||||
RadioSize int `yaml:"radio_size"`
|
RadioSize int `yaml:"radio_size"`
|
||||||
RadioSizeMax int `yaml:"radio_size_max"`
|
RadioSizeMax int `yaml:"radio_size_max"`
|
||||||
@@ -119,18 +120,26 @@ func Default() Config {
|
|||||||
SkipMaxDurationPlayedMs: 30000,
|
SkipMaxDurationPlayedMs: 30000,
|
||||||
},
|
},
|
||||||
Recommendation: RecommendationConfig{
|
Recommendation: RecommendationConfig{
|
||||||
BaseWeight: 1.0,
|
BaseWeight: 1.0,
|
||||||
LikeBoost: 2.0,
|
LikeBoost: 2.0,
|
||||||
RecencyWeight: 1.0,
|
RecencyWeight: 1.0,
|
||||||
SkipPenalty: 1.0,
|
SkipPenalty: 1.0,
|
||||||
JitterMagnitude: 0.1,
|
JitterMagnitude: 0.1,
|
||||||
ContextWeight: 2.0,
|
ContextWeight: 2.0,
|
||||||
SimilarityWeight: 2.0,
|
SimilarityWeight: 2.0,
|
||||||
|
// Radio is seed-directed (the user picked a direction), so taste
|
||||||
|
// is a lighter nudge here than in the daily mixes (1.5).
|
||||||
|
TasteWeight: 1.0,
|
||||||
RecentlyPlayedHours: 1,
|
RecentlyPlayedHours: 1,
|
||||||
RadioSize: 50,
|
RadioSize: 50,
|
||||||
RadioSizeMax: 200,
|
RadioSizeMax: 200,
|
||||||
},
|
},
|
||||||
Library: LibraryConfig{
|
Library: LibraryConfig{
|
||||||
|
// On by default so a fresh install populates its library on
|
||||||
|
// first boot without the operator hunting for a "scan now"
|
||||||
|
// button. Scans are incremental (unchanged files skipped by
|
||||||
|
// mtime), so every later restart is just a cheap directory walk.
|
||||||
|
ScanOnStartup: true,
|
||||||
CoverArtFromMBCAA: true,
|
CoverArtFromMBCAA: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ func TestEnvOverrides(t *testing.T) {
|
|||||||
func TestLibraryEnvOverrides(t *testing.T) {
|
func TestLibraryEnvOverrides(t *testing.T) {
|
||||||
stubStreamSecret(t)
|
stubStreamSecret(t)
|
||||||
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
|
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
|
||||||
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true")
|
// Default is true; set false so the override path is actually exercised.
|
||||||
|
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "false")
|
||||||
|
|
||||||
cfg, err := Load("")
|
cfg, err := Load("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -112,8 +113,8 @@ func TestLibraryEnvOverrides(t *testing.T) {
|
|||||||
t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p)
|
t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !cfg.Library.ScanOnStartup {
|
if cfg.Library.ScanOnStartup {
|
||||||
t.Error("scan_on_startup = false, want true")
|
t.Error("scan_on_startup = true, want false (env override of the true default)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -497,6 +497,20 @@ type SystemPlaylistRun struct {
|
|||||||
LastError *string
|
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 {
|
type Track struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Title string
|
Title string
|
||||||
@@ -549,3 +563,17 @@ type UserInvite struct {
|
|||||||
RedeemedAt pgtype.Timestamptz
|
RedeemedAt pgtype.Timestamptz
|
||||||
RedeemedBy pgtype.UUID
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -737,6 +737,17 @@ likes_overlap AS (
|
|||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $8
|
LIMIT $8
|
||||||
),
|
),
|
||||||
|
taste_overlap AS (
|
||||||
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||||
|
WHERE tpa.user_id = $1
|
||||||
|
AND tpa.weight > 0
|
||||||
|
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
ORDER BY tpa.weight DESC, t.id
|
||||||
|
LIMIT $10
|
||||||
|
),
|
||||||
random_fill AS (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -747,6 +758,7 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM similar_artists
|
UNION SELECT track_id FROM similar_artists
|
||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
|
UNION SELECT track_id FROM taste_overlap
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
LIMIT $9
|
||||||
@@ -763,6 +775,7 @@ FROM (
|
|||||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id
|
||||||
@@ -790,6 +803,7 @@ type LoadRadioCandidatesV2Params struct {
|
|||||||
Limit_3 int32
|
Limit_3 int32
|
||||||
Limit_4 int32
|
Limit_4 int32
|
||||||
Limit_5 int32
|
Limit_5 int32
|
||||||
|
Limit_6 int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadRadioCandidatesV2Row struct {
|
type LoadRadioCandidatesV2Row struct {
|
||||||
@@ -801,11 +815,15 @@ type LoadRadioCandidatesV2Row struct {
|
|||||||
SimilarityScore interface{}
|
SimilarityScore interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// M4c: similarity-driven candidate pool. 5-way UNION:
|
// M4c: similarity-driven candidate pool. 6-way UNION:
|
||||||
//
|
//
|
||||||
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||||
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||||||
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
|
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||||||
|
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||||
|
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||||||
|
// enter the pool even when the similarity/random arms miss them; scored
|
||||||
|
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||||
//
|
//
|
||||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||||
@@ -819,6 +837,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
arg.Limit_3,
|
arg.Limit_3,
|
||||||
arg.Limit_4,
|
arg.Limit_4,
|
||||||
arg.Limit_5,
|
arg.Limit_5,
|
||||||
|
arg.Limit_6,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -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,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);
|
||||||
@@ -33,10 +33,14 @@ WHERE t.id <> $2
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- name: LoadRadioCandidatesV2 :many
|
-- name: LoadRadioCandidatesV2 :many
|
||||||
-- M4c: similarity-driven candidate pool. 5-way UNION:
|
-- M4c: similarity-driven candidate pool. 6-way UNION:
|
||||||
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||||
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||||||
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
|
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||||||
|
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||||
|
-- positively-weighted taste-profile artists, so taste-relevant tracks
|
||||||
|
-- enter the pool even when the similarity/random arms miss them; scored
|
||||||
|
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||||
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
|
|
||||||
WITH
|
WITH
|
||||||
@@ -109,6 +113,17 @@ likes_overlap AS (
|
|||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $8
|
LIMIT $8
|
||||||
),
|
),
|
||||||
|
taste_overlap AS (
|
||||||
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||||
|
WHERE tpa.user_id = $1
|
||||||
|
AND tpa.weight > 0
|
||||||
|
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
ORDER BY tpa.weight DESC, t.id
|
||||||
|
LIMIT $10
|
||||||
|
),
|
||||||
random_fill AS (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -119,6 +134,7 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM similar_artists
|
UNION SELECT track_id FROM similar_artists
|
||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
|
UNION SELECT track_id FROM taste_overlap
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
LIMIT $9
|
||||||
@@ -135,6 +151,7 @@ FROM (
|
|||||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- 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.
|
||||||
|
|
||||||
|
-- name: RecommendationSourceMetricsForUser :many
|
||||||
|
-- $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).
|
||||||
|
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;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- 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).
|
||||||
|
|
||||||
|
-- name: ListPlayEngagementInputsForUser :many
|
||||||
|
-- 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.
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
-- name: ListLikedTrackTasteInputsForUser :many
|
||||||
|
-- (artist_id, genre) for each track the user has explicitly liked. Feeds the
|
||||||
|
-- track-like bonus into the liked track's artist and tags.
|
||||||
|
SELECT t.artist_id, t.genre
|
||||||
|
FROM general_likes gl
|
||||||
|
JOIN tracks t ON t.id = gl.track_id
|
||||||
|
WHERE gl.user_id = $1;
|
||||||
|
|
||||||
|
-- name: ListLikedArtistIDsForUser :many
|
||||||
|
-- Artists the user has explicitly liked; feeds the artist-like bonus.
|
||||||
|
SELECT artist_id FROM general_likes_artists WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: DeleteTasteProfileArtistsForUser :exec
|
||||||
|
DELETE FROM taste_profile_artists WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: InsertTasteProfileArtist :exec
|
||||||
|
INSERT INTO taste_profile_artists (user_id, artist_id, weight)
|
||||||
|
VALUES ($1, $2, $3);
|
||||||
|
|
||||||
|
-- name: DeleteTasteProfileTagsForUser :exec
|
||||||
|
DELETE FROM taste_profile_tags WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: InsertTasteProfileTag :exec
|
||||||
|
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||||
|
VALUES ($1, $2, $3);
|
||||||
|
|
||||||
|
-- name: ListTasteProfileArtistsForUser :many
|
||||||
|
-- Top-weighted taste artists (phase-2 consumption + tests).
|
||||||
|
SELECT artist_id, weight
|
||||||
|
FROM taste_profile_artists
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY weight DESC
|
||||||
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- name: ListTasteProfileTagsForUser :many
|
||||||
|
SELECT tag, weight
|
||||||
|
FROM taste_profile_tags
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY weight DESC
|
||||||
|
LIMIT $2;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
-- "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.
|
||||||
|
|
||||||
|
-- name: CountListeningSignalForUser :one
|
||||||
|
-- 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.
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- name: DeleteYouMightLikeAlbumsForUser :exec
|
||||||
|
DELETE FROM you_might_like_albums WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: InsertYouMightLikeAlbum :exec
|
||||||
|
INSERT INTO you_might_like_albums (user_id, album_id, rank)
|
||||||
|
VALUES ($1, $2, $3);
|
||||||
|
|
||||||
|
-- name: DeleteYouMightLikeArtistsForUser :exec
|
||||||
|
DELETE FROM you_might_like_artists WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: InsertYouMightLikeArtist :exec
|
||||||
|
INSERT INTO you_might_like_artists (user_id, artist_id, rank)
|
||||||
|
VALUES ($1, $2, $3);
|
||||||
|
|
||||||
|
-- name: ListYouMightLikeAlbumsForUser :many
|
||||||
|
-- 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).
|
||||||
|
SELECT sqlc.embed(albums), 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;
|
||||||
|
|
||||||
|
-- name: ListYouMightLikeArtistsForUser :many
|
||||||
|
-- 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.
|
||||||
|
SELECT sqlc.embed(artists),
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- name: ListYouMightLikeArtistFallbackForUser :many
|
||||||
|
-- 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.
|
||||||
|
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 sqlc.embed(artists),
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- name: ListYouMightLikeAlbumFallbackForUser :many
|
||||||
|
-- Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS
|
||||||
|
-- the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser.
|
||||||
|
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 sqlc.embed(albums), 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;
|
||||||
@@ -180,6 +180,12 @@ func (w *Writer) RecordPlayEnded(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Idempotency for the client's durable close-by-id replay: if the
|
||||||
|
// row is already closed, re-applying the close is harmless (the
|
||||||
|
// UPDATE is last-write-wins) but a second skip_events insert must
|
||||||
|
// NOT fire — a lost-response retry would otherwise double the
|
||||||
|
// skip signal. See the 2026-06-11 contract audit.
|
||||||
|
alreadyEnded := ev.EndedAt.Valid
|
||||||
ratio := 0.0
|
ratio := 0.0
|
||||||
if track.DurationMs > 0 {
|
if track.DurationMs > 0 {
|
||||||
ratio = float64(durationPlayedMs) / float64(track.DurationMs)
|
ratio = float64(durationPlayedMs) / float64(track.DurationMs)
|
||||||
@@ -196,7 +202,7 @@ func (w *Writer) RecordPlayEnded(
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isSkip {
|
if isSkip && !alreadyEnded {
|
||||||
if _, err := q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{
|
if _, err := q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{
|
||||||
UserID: ev.UserID,
|
UserID: ev.UserID,
|
||||||
TrackID: ev.TrackID,
|
TrackID: ev.TrackID,
|
||||||
@@ -356,6 +362,24 @@ func (w *Writer) RecordOfflinePlay(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Idempotency: the offline queue is at-least-once, so a response lost
|
||||||
|
// after commit makes the client re-fire the same play. The replayed
|
||||||
|
// payload carries the original `at`, so an exact (user, track,
|
||||||
|
// started_at) match means we already recorded it — no-op instead of a
|
||||||
|
// duplicate history row. See the 2026-06-11 contract audit.
|
||||||
|
var dupID pgtype.UUID
|
||||||
|
err = tx.QueryRow(ctx,
|
||||||
|
`SELECT id FROM play_events
|
||||||
|
WHERE user_id = $1 AND track_id = $2 AND started_at = $3
|
||||||
|
LIMIT 1`,
|
||||||
|
userID, trackID, pgtype.Timestamptz{Time: at, Valid: true},
|
||||||
|
).Scan(&dupID)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := w.autoClosePriorOpen(ctx, q, userID, at); err != nil {
|
if err := w.autoClosePriorOpen(ctx, q, userID, at); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -471,10 +495,15 @@ func (w *Writer) captureSessionVector(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// autoClosePriorOpen closes any open (ended_at IS NULL) play_event for the
|
// autoClosePriorOpen closes any open (ended_at IS NULL) play_event for the
|
||||||
// user. Sets ended_at = at, duration_played_ms = min(at - started_at, track
|
// user. Sets ended_at = at and duration_played_ms = min(at - started_at,
|
||||||
// duration), was_skipped = true. Skip rule is NOT applied — auto-closed
|
// track duration) — our best estimate of how long the orphan played when
|
||||||
// rows are flagged skipped regardless because we don't know what the user
|
// the client never sent play_ended (e.g. backgrounded mid-track). The same
|
||||||
// actually heard.
|
// skip rule as RecordPlayEnded is then applied to that estimate: an orphan
|
||||||
|
// that sat open past the track's length reads as ratio ~1 → a real play, so
|
||||||
|
// a fully-listened track that lost its close still lands in history instead
|
||||||
|
// of being force-hidden. Deliberately writes no skip_events row — an
|
||||||
|
// auto-close is an ambiguous signal, not a deliberate skip, and must not
|
||||||
|
// feed the skip-ratio / recommendation signal.
|
||||||
func (w *Writer) autoClosePriorOpen(
|
func (w *Writer) autoClosePriorOpen(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
q *dbq.Queries,
|
q *dbq.Queries,
|
||||||
@@ -503,6 +532,7 @@ func (w *Writer) autoClosePriorOpen(
|
|||||||
if track.DurationMs > 0 {
|
if track.DurationMs > 0 {
|
||||||
ratio = float64(elapsedMs) / float64(track.DurationMs)
|
ratio = float64(elapsedMs) / float64(track.DurationMs)
|
||||||
}
|
}
|
||||||
|
isSkip := ratio < w.skipMaxCompletionRatio && elapsedMs < w.skipMaxDurationPlayedMs
|
||||||
ratioPtr := ratio
|
ratioPtr := ratio
|
||||||
durPtr := elapsedMs
|
durPtr := elapsedMs
|
||||||
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
||||||
@@ -510,11 +540,11 @@ func (w *Writer) autoClosePriorOpen(
|
|||||||
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||||
DurationPlayedMs: &durPtr,
|
DurationPlayedMs: &durPtr,
|
||||||
CompletionRatio: &ratioPtr,
|
CompletionRatio: &ratioPtr,
|
||||||
WasSkipped: true,
|
WasSkipped: isSkip,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
w.logger.Info("playevents: auto-closed prior open row",
|
w.logger.Info("playevents: auto-closed prior open row",
|
||||||
"play_event_id", prior.ID, "elapsed_ms", elapsedMs)
|
"play_event_id", prior.ID, "elapsed_ms", elapsedMs, "was_skipped", isSkip)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,14 +122,32 @@ func TestRecordPlayStarted_AutoClosesPriorOpenRow(t *testing.T) {
|
|||||||
if !prior.EndedAt.Valid {
|
if !prior.EndedAt.Valid {
|
||||||
t.Errorf("prior should be closed")
|
t.Errorf("prior should be closed")
|
||||||
}
|
}
|
||||||
if !prior.WasSkipped {
|
// 45s elapsed on a 200s track clears the 30s duration threshold, so the
|
||||||
t.Errorf("prior should be marked was_skipped=true (auto-close convention)")
|
// skip rule's AND fails -> NOT a skip. An orphan that played long enough
|
||||||
|
// to count must still land in history rather than being force-hidden.
|
||||||
|
if prior.WasSkipped {
|
||||||
|
t.Errorf("auto-close at 45s should apply the skip rule -> was_skipped=false")
|
||||||
}
|
}
|
||||||
if prior.DurationPlayedMs == nil || *prior.DurationPlayedMs != 45_000 {
|
if prior.DurationPlayedMs == nil || *prior.DurationPlayedMs != 45_000 {
|
||||||
t.Errorf("duration_played_ms = %v, want 45000", prior.DurationPlayedMs)
|
t.Errorf("duration_played_ms = %v, want 45000", prior.DurationPlayedMs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRecordPlayStarted_AutoCloseShortPlayIsSkip(t *testing.T) {
|
||||||
|
f := newFixture(t, 200_000)
|
||||||
|
t1 := time.Now().UTC()
|
||||||
|
first, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
|
||||||
|
// 5s elapsed: ratio 0.025 < 0.5 AND 5000 < 30000 -> both fail -> a skip.
|
||||||
|
t2 := t1.Add(5 * time.Second)
|
||||||
|
if _, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t2); err != nil {
|
||||||
|
t.Fatalf("second: %v", err)
|
||||||
|
}
|
||||||
|
prior, _ := f.q.GetPlayEventByID(context.Background(), first.PlayEventID)
|
||||||
|
if !prior.WasSkipped {
|
||||||
|
t.Errorf("auto-close at 5s should be was_skipped=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRecordPlayStarted_AutoCloseCapsAtTrackDuration(t *testing.T) {
|
func TestRecordPlayStarted_AutoCloseCapsAtTrackDuration(t *testing.T) {
|
||||||
f := newFixture(t, 60_000) // 60s track
|
f := newFixture(t, 60_000) // 60s track
|
||||||
t1 := time.Now().UTC()
|
t1 := time.Now().UTC()
|
||||||
@@ -201,6 +219,55 @@ func TestRecordPlayEnded_DurationOver50Percent_NotSkip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A lost-response retry of an offline play (same user/track/started_at)
|
||||||
|
// must not create a second history row. Guards the at-least-once replay
|
||||||
|
// path added in the 2026-06-11 audit.
|
||||||
|
func TestRecordOfflinePlay_DedupsByUserTrackStartedAt(t *testing.T) {
|
||||||
|
f := newFixture(t, 200_000)
|
||||||
|
ctx := context.Background()
|
||||||
|
at := time.Now().UTC()
|
||||||
|
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
|
||||||
|
t.Fatalf("first offline: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
|
||||||
|
t.Fatalf("replay offline: %v", err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := f.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM play_events WHERE user_id=$1 AND track_id=$2 AND started_at=$3",
|
||||||
|
f.user, f.track, pgtype.Timestamptz{Time: at, Valid: true}).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("play_events count = %d, want 1 (deduped)", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-closing an already-ended row (the client's durable close-by-id replay)
|
||||||
|
// updates the row but must NOT insert a second skip_events row.
|
||||||
|
func TestRecordPlayEnded_IdempotentClose_NoDuplicateSkipEvent(t *testing.T) {
|
||||||
|
f := newFixture(t, 200_000)
|
||||||
|
ctx := context.Background()
|
||||||
|
t1 := time.Now().UTC()
|
||||||
|
res, _ := f.w.RecordPlayStarted(ctx, f.user, f.track, "c", t1)
|
||||||
|
t2 := t1.Add(10 * time.Second) // 5% / 10s -> skip
|
||||||
|
if err := f.w.RecordPlayEnded(ctx, res.PlayEventID, 10_000, t2); err != nil {
|
||||||
|
t.Fatalf("first end: %v", err)
|
||||||
|
}
|
||||||
|
if err := f.w.RecordPlayEnded(ctx, res.PlayEventID, 10_000, t2); err != nil {
|
||||||
|
t.Fatalf("replay end: %v", err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := f.pool.QueryRow(ctx,
|
||||||
|
"SELECT COUNT(*) FROM skip_events WHERE user_id=$1 AND track_id=$2",
|
||||||
|
f.user, f.track).Scan(&count); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("skip_events count = %d, want 1 (idempotent close)", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRecordPlaySkipped_AlwaysSkipFlagAndSkipEventRow(t *testing.T) {
|
func TestRecordPlaySkipped_AlwaysSkipFlagAndSkipEventRow(t *testing.T) {
|
||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
t1 := time.Now().UTC()
|
t1 := time.Now().UTC()
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dailyRebuildHour is the local-time hour at which each user's system
|
// dailyRebuildHour is the local-time hour at which each user's system
|
||||||
@@ -46,14 +48,21 @@ type Scheduler struct {
|
|||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
dataDir string
|
dataDir string
|
||||||
|
bus *eventbus.Bus // #968: announce rebuilds so clients self-refresh
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
|
jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScheduler builds an idle scheduler. Caller must invoke Start
|
// NewScheduler builds an idle scheduler. Caller must invoke Start
|
||||||
// before any builds fire.
|
// before any builds fire. bus may be nil (tests); rebuild events are
|
||||||
func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Scheduler, error) {
|
// then skipped.
|
||||||
|
func NewScheduler(
|
||||||
|
pool *pgxpool.Pool,
|
||||||
|
logger *slog.Logger,
|
||||||
|
dataDir string,
|
||||||
|
bus *eventbus.Bus,
|
||||||
|
) (*Scheduler, error) {
|
||||||
g, err := gocron.NewScheduler()
|
g, err := gocron.NewScheduler()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init gocron: %w", err)
|
return nil, fmt.Errorf("init gocron: %w", err)
|
||||||
@@ -63,6 +72,7 @@ func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Sch
|
|||||||
pool: pool,
|
pool: pool,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
|
bus: bus,
|
||||||
jobs: map[pgtype.UUID]uuid.UUID{},
|
jobs: map[pgtype.UUID]uuid.UUID{},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -158,10 +168,7 @@ func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz s
|
|||||||
job, err := s.gocron.NewJob(
|
job, err := s.gocron.NewJob(
|
||||||
gocron.CronJob(cronExpr, false /* withSeconds */),
|
gocron.CronJob(cronExpr, false /* withSeconds */),
|
||||||
gocron.NewTask(func() {
|
gocron.NewTask(func() {
|
||||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, time.Now(), s.dataDir); err != nil {
|
s.rebuildUserDaily(ctx, userID, time.Now())
|
||||||
s.logger.Warn("scheduler: build failed",
|
|
||||||
"user_id", uuidStringPL(userID), "err", err)
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -220,13 +227,44 @@ func (s *Scheduler) reconcile(ctx context.Context) {
|
|||||||
// whose scheduled fire was missed during downtime get caught up.
|
// whose scheduled fire was missed during downtime get caught up.
|
||||||
func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) {
|
func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) {
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, u.ID, time.Now(), s.dataDir); err != nil {
|
s.rebuildUserDaily(ctx, u.ID, time.Now())
|
||||||
s.logger.Warn("scheduler: startup catch-up build failed",
|
|
||||||
"user_id", uuidStringPL(u.ID), "err", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rebuildUserDaily recomputes the user's taste profile, then rebuilds their
|
||||||
|
// system playlists + You-might-like rows. Taste runs first so the playlist
|
||||||
|
// build can read a fresh profile (phase-2 consumption); both steps are
|
||||||
|
// best-effort and a failure in one is logged without blocking the other.
|
||||||
|
func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) {
|
||||||
|
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, taste.DefaultConfig()); err != nil {
|
||||||
|
s.logger.Warn("scheduler: taste profile rebuild failed",
|
||||||
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
|
}
|
||||||
|
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil {
|
||||||
|
s.logger.Warn("scheduler: build failed",
|
||||||
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// #968: tell the user's connected clients their home content was
|
||||||
|
// regenerated, so a tab/app left open across the rebuild refreshes its
|
||||||
|
// system-playlist + You-might-like views (and a stale active queue can
|
||||||
|
// re-pull) instead of serving yesterday's snapshot until a manual reload.
|
||||||
|
s.publishRebuilt(userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publishRebuilt broadcasts a user-scoped "system playlists rebuilt" event.
|
||||||
|
// No-op when the bus is nil (test construction).
|
||||||
|
func (s *Scheduler) publishRebuilt(userID pgtype.UUID) {
|
||||||
|
if s.bus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.bus.Publish(eventbus.Event{
|
||||||
|
Kind: "playlist.system_rebuilt",
|
||||||
|
UserID: uuidStringPL(userID),
|
||||||
|
Data: map[string]any{},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Stop drains gocron and stops the scheduler loop.
|
// Stop drains gocron and stops the scheduler loop.
|
||||||
func (s *Scheduler) Stop() {
|
func (s *Scheduler) Stop() {
|
||||||
if err := s.gocron.Shutdown(); err != nil {
|
if err := s.gocron.Shutdown(); err != nil {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
package playlists
|
package playlists
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
@@ -147,6 +148,11 @@ var systemMixWeights = recommendation.ScoringWeights{
|
|||||||
JitterMagnitude: 0.1,
|
JitterMagnitude: 0.1,
|
||||||
ContextWeight: 0.5,
|
ContextWeight: 0.5,
|
||||||
SimilarityWeight: 1.5,
|
SimilarityWeight: 1.5,
|
||||||
|
// Taste profile (#796 phase 2): the daily mixes are the primary
|
||||||
|
// taste-driven surface, so they lean on it. TasteMatchScore is in
|
||||||
|
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
||||||
|
// while passive avoidance (negative) gently demotes.
|
||||||
|
TasteWeight: 1.5,
|
||||||
}
|
}
|
||||||
|
|
||||||
// forYouHeadN is the number of top-scored tracks that anchor the For-You
|
// forYouHeadN is the number of top-scored tracks that anchor the For-You
|
||||||
@@ -180,8 +186,21 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
|
|||||||
c recommendation.Candidate
|
c recommendation.Candidate
|
||||||
score float64
|
score float64
|
||||||
}
|
}
|
||||||
pairs := make([]scored, len(cands))
|
// Pin candidate order by track id before drawing jitter. The candidate
|
||||||
for i, c := range cands {
|
// query (LoadRadioCandidatesV2) has ORDER BY random() arms and no
|
||||||
|
// stable outer ordering, so DB row order varies call-to-call. Since
|
||||||
|
// the i-th candidate gets the i-th seeded jitter draw, an unstable
|
||||||
|
// input order would assign different jitter to the same track across
|
||||||
|
// same-day rebuilds and reorder near-ties — breaking the daily
|
||||||
|
// determinism this function promises. Sorting by id first makes the
|
||||||
|
// jitter assignment a function of (track, day) alone.
|
||||||
|
ordered := make([]recommendation.Candidate, len(cands))
|
||||||
|
copy(ordered, cands)
|
||||||
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
|
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
|
||||||
|
})
|
||||||
|
pairs := make([]scored, len(ordered))
|
||||||
|
for i, c := range ordered {
|
||||||
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)}
|
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)}
|
||||||
}
|
}
|
||||||
sort.SliceStable(pairs, func(i, j int) bool {
|
sort.SliceStable(pairs, func(i, j int) bool {
|
||||||
@@ -310,6 +329,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
|||||||
TagOverlap: 60,
|
TagOverlap: 60,
|
||||||
LikesOverlap: 40,
|
LikesOverlap: 40,
|
||||||
RandomFill: 150,
|
RandomFill: 150,
|
||||||
|
// For-You / You-might-like are the taste-driven surfaces, so pull a
|
||||||
|
// deep slice of the user's top taste-profile artists into the pool
|
||||||
|
// (#796 phase 2b). Empty for cold-start users (no profile yet).
|
||||||
|
TasteOverlap: 80,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +520,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
|||||||
built = append(built, out...)
|
built = append(built, out...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "You might like" Home rows reuse the same similarity engine but
|
||||||
|
// persist to dedicated album/artist tables (not playlist_tracks).
|
||||||
|
// Computed here (reads only) and atomic-replaced inside the tx below,
|
||||||
|
// alongside the system playlists. A failed/gated computation is
|
||||||
|
// handled by yml.built (leave prior rows vs. clear) — never fatal.
|
||||||
|
yml := buildYouMightLike(ctx, q, logger, userID, dateStr, now)
|
||||||
|
|
||||||
// Atomic replace inside a transaction.
|
// Atomic replace inside a transaction.
|
||||||
tx, err := pool.Begin(ctx)
|
tx, err := pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -530,6 +560,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
|||||||
createdIDs = append(createdIDs, id)
|
createdIDs = append(createdIDs, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if yml.built {
|
||||||
|
if err := persistYouMightLike(ctx, qtx, userID, yml); err != nil {
|
||||||
|
buildErr = fmt.Errorf("persist you-might-like: %w", err)
|
||||||
|
return buildErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Commit(ctx); err != nil {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
buildErr = fmt.Errorf("commit tx: %w", err)
|
buildErr = fmt.Errorf("commit tx: %w", err)
|
||||||
return buildErr
|
return buildErr
|
||||||
@@ -695,6 +732,12 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
|
|||||||
// (pgtype.UUID's String method exists on some pgx versions but not others;
|
// (pgtype.UUID's String method exists on some pgx versions but not others;
|
||||||
// also the playlists package needs this independent of test helpers.)
|
// also the playlists package needs this independent of test helpers.)
|
||||||
// Suffix `PL` distinguishes it from any test helper named uuidString.
|
// Suffix `PL` distinguishes it from any test helper named uuidString.
|
||||||
|
// uuidLessPL reports whether a sorts before b by raw 16-byte value. Used
|
||||||
|
// to pin candidate order deterministically before jitter assignment.
|
||||||
|
func uuidLessPL(a, b pgtype.UUID) bool {
|
||||||
|
return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0
|
||||||
|
}
|
||||||
|
|
||||||
func uuidStringPL(u pgtype.UUID) string {
|
func uuidStringPL(u pgtype.UUID) string {
|
||||||
if !u.Valid {
|
if !u.Valid {
|
||||||
return "<nil>"
|
return "<nil>"
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
// you_might_like.go builds the per-user "You might like" Home rows
|
||||||
|
// (#790): in-library albums/artists the listener doesn't actively spin
|
||||||
|
// but is predicted to enjoy. It reuses the For-You candidate engine
|
||||||
|
// (similarity + like-weighted scoring) and rolls the per-track scores up
|
||||||
|
// to album/artist granularity, gated on a minimum listening history so a
|
||||||
|
// near-empty profile ships nothing rather than noise.
|
||||||
|
//
|
||||||
|
// Built in the same daily BuildSystemPlaylists pass and atomic-replaced
|
||||||
|
// alongside the system playlists; read back by /api/home.
|
||||||
|
package playlists
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Cold-start gate. Below this much *real* listening the similarity
|
||||||
|
// roll-up degrades toward random fill, so You-might-like ships
|
||||||
|
// nothing rather than arbitrary tiles. Both bars must clear: distinct
|
||||||
|
// tracks rules out "played 3 songs on repeat," distinct artists rules
|
||||||
|
// out "hammered one album." Conservative defaults; raise if early
|
||||||
|
// rows feel random on thin libraries.
|
||||||
|
youMightLikeMinDistinctTracks = 20
|
||||||
|
youMightLikeMinDistinctArtists = 5
|
||||||
|
|
||||||
|
// Persisted depth — a few more than the rendered HomeYouMightLikeLimit
|
||||||
|
// so the read-time cross-section dedup (vs Most Played / Rediscover)
|
||||||
|
// has headroom before trimming.
|
||||||
|
youMightLikeAlbumsN = 12
|
||||||
|
youMightLikeArtistsN = 12
|
||||||
|
|
||||||
|
// One strongly-matched artist shouldn't fill the albums row.
|
||||||
|
youMightLikeMaxAlbumsPerArtist = 2
|
||||||
|
|
||||||
|
// Aggregation: sum of an entity's top-K track scores. Favors albums/
|
||||||
|
// artists with several good matches over a single outlier track.
|
||||||
|
youMightLikeAggTopK = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// youMightLikeResult carries one day's roll-up. built=false means the
|
||||||
|
// computation failed and the caller must leave any existing rows intact;
|
||||||
|
// built=true with nil slices means "explicitly empty" (cold-start gate or
|
||||||
|
// no candidates) and the caller should clear the user's rows.
|
||||||
|
type youMightLikeResult struct {
|
||||||
|
albumIDs []pgtype.UUID
|
||||||
|
artistIDs []pgtype.UUID
|
||||||
|
built bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildYouMightLike computes the user's daily album/artist rolls. Reads
|
||||||
|
// only — the caller persists inside the build tx. Never returns an error:
|
||||||
|
// a failure logs and yields built=false so the prior day's rows survive.
|
||||||
|
func buildYouMightLike(
|
||||||
|
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||||
|
userID pgtype.UUID, dateStr string, now time.Time,
|
||||||
|
) youMightLikeResult {
|
||||||
|
signal, err := q.CountListeningSignalForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("you-might-like: listening-signal query failed; skipping",
|
||||||
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
|
return youMightLikeResult{built: false}
|
||||||
|
}
|
||||||
|
if signal.DistinctTracks < youMightLikeMinDistinctTracks ||
|
||||||
|
signal.DistinctArtists < youMightLikeMinDistinctArtists {
|
||||||
|
// Cold start: not enough listening to recommend from. built=true
|
||||||
|
// clears any stale rows (normally none) and ships an empty row.
|
||||||
|
return youMightLikeResult{built: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
seeds, err := q.PickTopPlayedTracksForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("you-might-like: seed query failed; skipping",
|
||||||
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
|
return youMightLikeResult{built: false}
|
||||||
|
}
|
||||||
|
seed := pickForYouSeedForDay(seeds, userID, dateStr)
|
||||||
|
if !seed.Valid {
|
||||||
|
return youMightLikeResult{built: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
zeroVec := recommendation.SessionVector{Seed: true}
|
||||||
|
// You-might-like surfaces in-library artists the user does NOT actively
|
||||||
|
// engage with, so it deliberately skips the taste_overlap arm — that arm
|
||||||
|
// pulls top-taste (mostly already-played) artists, which would crowd the
|
||||||
|
// pool with entities the read-time dedup then strips. For-You/radio keep it.
|
||||||
|
ymlLimits := systemForYouSourceLimits()
|
||||||
|
ymlLimits.TasteOverlap = 0
|
||||||
|
cands, err := recommendation.LoadCandidatesFromSimilarity(
|
||||||
|
ctx, q, userID, seed, 1, zeroVec,
|
||||||
|
[]pgtype.UUID{seed}, ymlLimits,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("you-might-like: candidate load failed; skipping",
|
||||||
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
|
return youMightLikeResult{built: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
albumIDs, artistIDs := rollUpCandidates(cands, userID, dateStr, now)
|
||||||
|
return youMightLikeResult{albumIDs: albumIDs, artistIDs: artistIDs, built: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoredEntity is an album or artist with its aggregated score.
|
||||||
|
type scoredEntity struct {
|
||||||
|
id pgtype.UUID
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// rollUpCandidates scores every candidate track (systemMixWeights, jitter
|
||||||
|
// seeded by userIDHash so near-ties rotate day-to-day) and aggregates the
|
||||||
|
// scores up to album and artist via sum-of-top-K. Returns the top-N album
|
||||||
|
// and artist IDs, with a per-artist cap on the album list.
|
||||||
|
func rollUpCandidates(
|
||||||
|
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
|
||||||
|
) (albumIDs, artistIDs []pgtype.UUID) {
|
||||||
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||||
|
albumScores := map[pgtype.UUID][]float64{}
|
||||||
|
artistScores := map[pgtype.UUID][]float64{}
|
||||||
|
albumArtist := map[pgtype.UUID]pgtype.UUID{}
|
||||||
|
for _, c := range cands {
|
||||||
|
s := recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)
|
||||||
|
if c.Track.AlbumID.Valid {
|
||||||
|
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
|
||||||
|
albumArtist[c.Track.AlbumID] = c.Track.ArtistID
|
||||||
|
}
|
||||||
|
if c.Track.ArtistID.Valid {
|
||||||
|
artistScores[c.Track.ArtistID] = append(artistScores[c.Track.ArtistID], s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rankedAlbums := capAlbumsPerArtist(
|
||||||
|
rankEntities(albumScores, dateStr), albumArtist, youMightLikeMaxAlbumsPerArtist)
|
||||||
|
albumIDs = topNEntityIDs(rankedAlbums, youMightLikeAlbumsN)
|
||||||
|
artistIDs = topNEntityIDs(rankEntities(artistScores, dateStr), youMightLikeArtistsN)
|
||||||
|
return albumIDs, artistIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
// rankEntities aggregates each entity's track scores (sum-of-top-K) and
|
||||||
|
// returns them sorted by score DESC, ties broken deterministically by
|
||||||
|
// tieBreakHash(id, dateStr) so ordering is stable within a day.
|
||||||
|
func rankEntities(scoresByEntity map[pgtype.UUID][]float64, dateStr string) []scoredEntity {
|
||||||
|
out := make([]scoredEntity, 0, len(scoresByEntity))
|
||||||
|
for id, scores := range scoresByEntity {
|
||||||
|
out = append(out, scoredEntity{id: id, score: sumTopK(scores, youMightLikeAggTopK)})
|
||||||
|
}
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].score != out[j].score {
|
||||||
|
return out[i].score > out[j].score
|
||||||
|
}
|
||||||
|
return tieBreakHash(out[i].id, dateStr) < tieBreakHash(out[j].id, dateStr)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// sumTopK sorts scores DESC and sums the highest k.
|
||||||
|
func sumTopK(scores []float64, k int) float64 {
|
||||||
|
sort.Sort(sort.Reverse(sort.Float64Slice(scores)))
|
||||||
|
sum := 0.0
|
||||||
|
for i := 0; i < len(scores) && i < k; i++ {
|
||||||
|
sum += scores[i]
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
// capAlbumsPerArtist drops albums beyond maxPerArtist for any one artist,
|
||||||
|
// preserving input (score) order.
|
||||||
|
func capAlbumsPerArtist(
|
||||||
|
albums []scoredEntity, albumArtist map[pgtype.UUID]pgtype.UUID, maxPerArtist int,
|
||||||
|
) []scoredEntity {
|
||||||
|
perArtist := map[pgtype.UUID]int{}
|
||||||
|
out := make([]scoredEntity, 0, len(albums))
|
||||||
|
for _, a := range albums {
|
||||||
|
art := albumArtist[a.id]
|
||||||
|
if art.Valid {
|
||||||
|
if perArtist[art] >= maxPerArtist {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
perArtist[art]++
|
||||||
|
}
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// topNEntityIDs truncates to n and projects the IDs in rank order.
|
||||||
|
func topNEntityIDs(entities []scoredEntity, n int) []pgtype.UUID {
|
||||||
|
if len(entities) > n {
|
||||||
|
entities = entities[:n]
|
||||||
|
}
|
||||||
|
out := make([]pgtype.UUID, 0, len(entities))
|
||||||
|
for _, e := range entities {
|
||||||
|
out = append(out, e.id)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistYouMightLike atomic-replaces the user's You-might-like rows
|
||||||
|
// inside the build tx. Called only when the roll-up was freshly built;
|
||||||
|
// a failed computation leaves the prior rows untouched.
|
||||||
|
func persistYouMightLike(
|
||||||
|
ctx context.Context, qtx *dbq.Queries, userID pgtype.UUID, r youMightLikeResult,
|
||||||
|
) error {
|
||||||
|
if err := qtx.DeleteYouMightLikeAlbumsForUser(ctx, userID); err != nil {
|
||||||
|
return fmt.Errorf("delete you-might-like albums: %w", err)
|
||||||
|
}
|
||||||
|
for i, id := range r.albumIDs {
|
||||||
|
if err := qtx.InsertYouMightLikeAlbum(ctx, dbq.InsertYouMightLikeAlbumParams{
|
||||||
|
UserID: userID, AlbumID: id, Rank: int32(i),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("insert you-might-like album: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := qtx.DeleteYouMightLikeArtistsForUser(ctx, userID); err != nil {
|
||||||
|
return fmt.Errorf("delete you-might-like artists: %w", err)
|
||||||
|
}
|
||||||
|
for i, id := range r.artistIDs {
|
||||||
|
if err := qtx.InsertYouMightLikeArtist(ctx, dbq.InsertYouMightLikeArtistParams{
|
||||||
|
UserID: userID, ArtistID: id, Rank: int32(i),
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("insert you-might-like artist: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package playlists_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
)
|
||||||
|
|
||||||
|
// countYouMightLike returns the persisted row counts for a user.
|
||||||
|
func countYouMightLike(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) (albums, artists int) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM you_might_like_albums WHERE user_id = $1`, userID,
|
||||||
|
).Scan(&albums); err != nil {
|
||||||
|
t.Fatalf("count albums: %v", err)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM you_might_like_artists WHERE user_id = $1`, userID,
|
||||||
|
).Scan(&artists); err != nil {
|
||||||
|
t.Fatalf("count artists: %v", err)
|
||||||
|
}
|
||||||
|
return albums, artists
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYouMightLike_ColdStartGate: a thin profile (3 artists × 3 tracks =
|
||||||
|
// 9 distinct tracks, below the 20-track / 5-artist gate) must produce no
|
||||||
|
// You-might-like rows — the build ships nothing rather than near-random
|
||||||
|
// tiles.
|
||||||
|
func TestYouMightLike_ColdStartGate(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
logger := discardLogger()
|
||||||
|
u, _ := seedActiveLibrary(t, pool, "ymlcold", 3, 3)
|
||||||
|
|
||||||
|
if err := playlists.BuildSystemPlaylists(
|
||||||
|
context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("build: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, artists := countYouMightLike(t, pool, u.ID)
|
||||||
|
if albums != 0 || artists != 0 {
|
||||||
|
t.Errorf("cold-start user should have no you-might-like rows; got %d albums, %d artists",
|
||||||
|
albums, artists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYouMightLike_SufficientActivityPopulates: a rich profile (8 artists
|
||||||
|
// × 4 tracks = 32 distinct tracks, clearing both gate bars) populates the
|
||||||
|
// You-might-like tables in the daily build.
|
||||||
|
func TestYouMightLike_SufficientActivityPopulates(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
logger := discardLogger()
|
||||||
|
u, _ := seedActiveLibrary(t, pool, "ymlrich", 8, 4)
|
||||||
|
|
||||||
|
if err := playlists.BuildSystemPlaylists(
|
||||||
|
context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("build: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, artists := countYouMightLike(t, pool, u.ID)
|
||||||
|
if albums == 0 {
|
||||||
|
t.Error("rich user should have you-might-like album rows; got 0")
|
||||||
|
}
|
||||||
|
if artists == 0 {
|
||||||
|
t.Error("rich user should have you-might-like artist rows; got 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestYouMightLike_AtomicReplace: a second build replaces rather than
|
||||||
|
// appends, so counts stay bounded by the persisted depth.
|
||||||
|
func TestYouMightLike_AtomicReplace(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
logger := discardLogger()
|
||||||
|
u, _ := seedActiveLibrary(t, pool, "ymlreplace", 8, 4)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := playlists.BuildSystemPlaylists(
|
||||||
|
ctx, pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("build %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
albums, artists := countYouMightLike(t, pool, u.ID)
|
||||||
|
if albums > 12 {
|
||||||
|
t.Errorf("albums = %d after two builds, want <= 12 (atomic replace)", albums)
|
||||||
|
}
|
||||||
|
if artists > 12 {
|
||||||
|
t.Errorf("artists = %d after two builds, want <= 12 (atomic replace)", artists)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package playlists
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fixedNow keeps recencyDecay stable across the pure roll-up tests.
|
||||||
|
var fixedNow = time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
// byteOf returns the per-test entity discriminator (makeCand sets it in
|
||||||
|
// Bytes[15]) so assertions can name albums/artists by their small int.
|
||||||
|
func byteOf(u pgtype.UUID) byte { return u.Bytes[15] }
|
||||||
|
|
||||||
|
// countWithByte reports how many of ids carry the given Bytes[15] marker.
|
||||||
|
func countWithByte(ids []pgtype.UUID, want byte) int {
|
||||||
|
n := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
if byteOf(id) == want {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSumTopK(t *testing.T) {
|
||||||
|
if got := sumTopK([]float64{1, 2, 3, 4}, 2); got != 7 {
|
||||||
|
t.Errorf("sumTopK top-2 of 1..4 = %v, want 7", got)
|
||||||
|
}
|
||||||
|
if got := sumTopK([]float64{5}, 3); got != 5 {
|
||||||
|
t.Errorf("sumTopK fewer-than-k = %v, want 5", got)
|
||||||
|
}
|
||||||
|
if got := sumTopK(nil, 3); got != 0 {
|
||||||
|
t.Errorf("sumTopK empty = %v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollUpCandidates_MultiMatchAlbumRanksFirst(t *testing.T) {
|
||||||
|
// Album 10 (artist 100): three solid matches. Album 20 (artist 200):
|
||||||
|
// one slightly-stronger single match. Sum-of-top-3 should rank the
|
||||||
|
// multi-match album ahead of the single-match one. Similarity gaps are
|
||||||
|
// wide enough that the ±0.1 jitter can't reorder the result.
|
||||||
|
cands := []recommendation.Candidate{
|
||||||
|
makeCand(1, 10, 100, 0.8),
|
||||||
|
makeCand(2, 10, 100, 0.8),
|
||||||
|
makeCand(3, 10, 100, 0.8),
|
||||||
|
makeCand(4, 20, 200, 0.95),
|
||||||
|
}
|
||||||
|
albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow)
|
||||||
|
if len(albums) < 2 {
|
||||||
|
t.Fatalf("want >=2 albums, got %d", len(albums))
|
||||||
|
}
|
||||||
|
if byteOf(albums[0]) != 10 {
|
||||||
|
t.Errorf("top album = %d, want 10 (multi-match beats single)", byteOf(albums[0]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollUpCandidates_PerArtistAlbumCap(t *testing.T) {
|
||||||
|
// Artist 100 spans albums 10/11/12; the album row caps any one artist
|
||||||
|
// at youMightLikeMaxAlbumsPerArtist (2). Artist 200's album 20 should
|
||||||
|
// still appear.
|
||||||
|
cands := []recommendation.Candidate{
|
||||||
|
makeCand(1, 10, 100, 0.9),
|
||||||
|
makeCand(2, 11, 100, 0.85),
|
||||||
|
makeCand(3, 12, 100, 0.8),
|
||||||
|
makeCand(4, 20, 200, 0.7),
|
||||||
|
}
|
||||||
|
albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow)
|
||||||
|
from100 := countWithByte(albums, 10) + countWithByte(albums, 11) + countWithByte(albums, 12)
|
||||||
|
if from100 > youMightLikeMaxAlbumsPerArtist {
|
||||||
|
t.Errorf("artist 100 contributed %d albums, want <= %d",
|
||||||
|
from100, youMightLikeMaxAlbumsPerArtist)
|
||||||
|
}
|
||||||
|
if countWithByte(albums, 20) != 1 {
|
||||||
|
t.Errorf("album 20 (artist 200) should survive the cap; got %d",
|
||||||
|
countWithByte(albums, 20))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRollUpCandidates_ArtistRollupDistinct(t *testing.T) {
|
||||||
|
// Three artists; the artist roll-up should surface all three distinct
|
||||||
|
// artist IDs (no per-artist cap on the artist row).
|
||||||
|
cands := []recommendation.Candidate{
|
||||||
|
makeCand(1, 10, 100, 0.9),
|
||||||
|
makeCand(2, 11, 101, 0.6),
|
||||||
|
makeCand(3, 12, 102, 0.3),
|
||||||
|
}
|
||||||
|
_, artists := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow)
|
||||||
|
seen := map[byte]bool{}
|
||||||
|
for _, a := range artists {
|
||||||
|
seen[byteOf(a)] = true
|
||||||
|
}
|
||||||
|
for _, want := range []byte{100, 101, 102} {
|
||||||
|
if !seen[want] {
|
||||||
|
t.Errorf("artist %d missing from roll-up", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapAlbumsPerArtist_DropsBeyondCap(t *testing.T) {
|
||||||
|
mk := func(albumN, artistN int) (pgtype.UUID, pgtype.UUID) {
|
||||||
|
var al, ar pgtype.UUID
|
||||||
|
al.Valid, ar.Valid = true, true
|
||||||
|
al.Bytes[15], ar.Bytes[15] = byte(albumN), byte(artistN)
|
||||||
|
return al, ar
|
||||||
|
}
|
||||||
|
a10, ar1 := mk(10, 1)
|
||||||
|
a11, _ := mk(11, 1)
|
||||||
|
a12, _ := mk(12, 1)
|
||||||
|
a20, ar2 := mk(20, 2)
|
||||||
|
albumArtist := map[pgtype.UUID]pgtype.UUID{a10: ar1, a11: ar1, a12: ar1, a20: ar2}
|
||||||
|
in := []scoredEntity{{a10, 3}, {a11, 2}, {a12, 1}, {a20, 0.5}}
|
||||||
|
got := capAlbumsPerArtist(in, albumArtist, 2)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len = %d, want 3 (artist 1 capped at 2 + artist 2's one)", len(got))
|
||||||
|
}
|
||||||
|
// Highest-scored two of artist 1 (albums 10, 11) kept; 12 dropped.
|
||||||
|
if byteOf(got[2].id) != 20 {
|
||||||
|
t.Errorf("third survivor = %d, want 20", byteOf(got[2].id))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,11 @@ func LoadCandidates(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
profile, err := LoadTasteProfile(ctx, q, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]Candidate, 0, len(rows))
|
out := make([]Candidate, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
var lpt *time.Time
|
var lpt *time.Time
|
||||||
@@ -52,6 +57,7 @@ func LoadCandidates(
|
|||||||
PlayCount: int(r.PlayCount),
|
PlayCount: int(r.PlayCount),
|
||||||
SkipCount: int(r.SkipCount),
|
SkipCount: int(r.SkipCount),
|
||||||
ContextualMatchScore: ctxScore,
|
ContextualMatchScore: ctxScore,
|
||||||
|
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -66,6 +72,10 @@ type CandidateSourceLimits struct {
|
|||||||
TagOverlap int
|
TagOverlap int
|
||||||
LikesOverlap int
|
LikesOverlap int
|
||||||
RandomFill int
|
RandomFill int
|
||||||
|
// TasteOverlap (#796 phase 2b): tracks by the user's top positively-
|
||||||
|
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
|
||||||
|
// users have an empty profile, so it contributes nothing anyway).
|
||||||
|
TasteOverlap int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
||||||
@@ -76,6 +86,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
|||||||
TagOverlap: 20,
|
TagOverlap: 20,
|
||||||
LikesOverlap: 20,
|
LikesOverlap: 20,
|
||||||
RandomFill: 30,
|
RandomFill: 30,
|
||||||
|
TasteOverlap: 20,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +118,7 @@ func LoadCandidatesFromSimilarity(
|
|||||||
Limit_3: int32(limits.TagOverlap),
|
Limit_3: int32(limits.TagOverlap),
|
||||||
Limit_4: int32(limits.LikesOverlap),
|
Limit_4: int32(limits.LikesOverlap),
|
||||||
Limit_5: int32(limits.RandomFill),
|
Limit_5: int32(limits.RandomFill),
|
||||||
|
Limit_6: int32(limits.TasteOverlap),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -117,6 +129,11 @@ func LoadCandidatesFromSimilarity(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
profile, err := LoadTasteProfile(ctx, q, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]Candidate, 0, len(rows))
|
out := make([]Candidate, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
var lpt *time.Time
|
var lpt *time.Time
|
||||||
@@ -141,6 +158,7 @@ func LoadCandidatesFromSimilarity(
|
|||||||
SkipCount: int(r.SkipCount),
|
SkipCount: int(r.SkipCount),
|
||||||
ContextualMatchScore: ctxScore,
|
ContextualMatchScore: ctxScore,
|
||||||
SimilarityScore: simScore,
|
SimilarityScore: simScore,
|
||||||
|
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,6 +262,61 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadCandidatesFromSimilarity_TasteOverlapArm (#796 phase 2b): a track by
|
||||||
|
// a positively-weighted taste-profile artist enters the pool via taste_overlap
|
||||||
|
// even with every other arm disabled; a negatively-weighted artist's track does
|
||||||
|
// not (the WHERE weight > 0 filter).
|
||||||
|
func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) {
|
||||||
|
f := newFixture(t, 2) // seed + 1 other, both by the fixture artist
|
||||||
|
seed := f.tracks[0]
|
||||||
|
target := f.tracks[1]
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Fixture artist gets a positive taste weight.
|
||||||
|
if _, err := f.pool.Exec(ctx,
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 5.0)`,
|
||||||
|
f.user, seed.ArtistID); err != nil {
|
||||||
|
t.Fatalf("insert taste (positive): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second artist with a NEGATIVE weight — its track must be excluded.
|
||||||
|
negArtist, _ := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "NegArtist", SortName: "NegArtist"})
|
||||||
|
negAlbum, _ := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "NegAlbum", SortTitle: "NegAlbum", ArtistID: negArtist.ID})
|
||||||
|
negTrack, _ := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: "NegTrack", AlbumID: negAlbum.ID, ArtistID: negArtist.ID,
|
||||||
|
FilePath: "/tmp/neg.flac", DurationMs: 180_000,
|
||||||
|
})
|
||||||
|
if _, err := f.pool.Exec(ctx,
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, -3.0)`,
|
||||||
|
f.user, negArtist.ID); err != nil {
|
||||||
|
t.Fatalf("insert taste (negative): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the taste_overlap arm is enabled.
|
||||||
|
limits := CandidateSourceLimits{TasteOverlap: 10}
|
||||||
|
got, err := LoadCandidatesFromSimilarity(
|
||||||
|
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
var sawTarget, sawNeg bool
|
||||||
|
for _, c := range got {
|
||||||
|
switch c.Track.ID {
|
||||||
|
case target.ID:
|
||||||
|
sawTarget = true
|
||||||
|
case negTrack.ID:
|
||||||
|
sawNeg = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawTarget {
|
||||||
|
t.Error("positive-taste-artist track missing (taste_overlap arm didn't contribute)")
|
||||||
|
}
|
||||||
|
if sawNeg {
|
||||||
|
t.Error("negative-taste-artist track present (weight > 0 filter failed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
|
func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
|
||||||
f := newFixture(t, 1) // just the seed
|
f := newFixture(t, 1) // just the seed
|
||||||
seed := f.tracks[0]
|
seed := f.tracks[0]
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ const (
|
|||||||
// artist from dominating the row. Two is enough variety; higher
|
// artist from dominating the row. Two is enough variety; higher
|
||||||
// reads as "this artist's discography" instead of "rediscover".
|
// reads as "this artist's discography" instead of "rediscover".
|
||||||
maxAlbumsPerArtistInRediscover = 2
|
maxAlbumsPerArtistInRediscover = 2
|
||||||
|
|
||||||
|
// HomeYouMightLikeLimit is the rendered count for each You-might-like
|
||||||
|
// row. The build persists a few more (youMightLikeAlbumsN/ArtistsN)
|
||||||
|
// so the read-time cross-section dedup has headroom; this query asks
|
||||||
|
// for the persisted depth and the Go layer trims to this after dedup.
|
||||||
|
HomeYouMightLikeLimit = 10
|
||||||
|
youMightLikeFetch = 12
|
||||||
)
|
)
|
||||||
|
|
||||||
// HomePayload is the composite returned by HomeData. All slices are
|
// HomePayload is the composite returned by HomeData. All slices are
|
||||||
@@ -45,6 +52,8 @@ type HomePayload struct {
|
|||||||
RediscoverArtists []dbq.ListRediscoverArtistsForUserRow
|
RediscoverArtists []dbq.ListRediscoverArtistsForUserRow
|
||||||
MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow
|
MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow
|
||||||
LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow
|
LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow
|
||||||
|
YouMightLikeAlbums []dbq.ListYouMightLikeAlbumsForUserRow
|
||||||
|
YouMightLikeArtists []dbq.ListYouMightLikeArtistsForUserRow
|
||||||
}
|
}
|
||||||
|
|
||||||
// HomeData runs five queries in parallel and assembles the payload.
|
// HomeData runs five queries in parallel and assembles the payload.
|
||||||
@@ -77,9 +86,11 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
|
|||||||
RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{},
|
RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{},
|
||||||
MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{},
|
MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{},
|
||||||
LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{},
|
LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{},
|
||||||
|
YouMightLikeAlbums: []dbq.ListYouMightLikeAlbumsForUserRow{},
|
||||||
|
YouMightLikeArtists: []dbq.ListYouMightLikeArtistsForUserRow{},
|
||||||
}
|
}
|
||||||
|
|
||||||
wg.Add(5)
|
wg.Add(7)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit)
|
rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit)
|
||||||
@@ -129,6 +140,28 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
|
|||||||
}
|
}
|
||||||
out.RediscoverArtists = rows
|
out.RediscoverArtists = rows
|
||||||
}()
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
rows, err := q.ListYouMightLikeAlbumsForUser(ctx, dbq.ListYouMightLikeAlbumsForUserParams{
|
||||||
|
UserID: userID, Limit: youMightLikeFetch,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fail("you_might_like_albums", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.YouMightLikeAlbums = rows
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
rows, err := q.ListYouMightLikeArtistsForUser(ctx, dbq.ListYouMightLikeArtistsForUserParams{
|
||||||
|
UserID: userID, Limit: youMightLikeFetch,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fail("you_might_like_artists", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out.YouMightLikeArtists = rows
|
||||||
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if firstErr != nil {
|
if firstErr != nil {
|
||||||
@@ -140,9 +173,155 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
|
|||||||
// the rediscover lists down toward HomeRediscoverLimit.
|
// the rediscover lists down toward HomeRediscoverLimit.
|
||||||
out.RediscoverAlbums = applyRediscoverAlbumFilters(out.RediscoverAlbums, out.MostPlayedTracks)
|
out.RediscoverAlbums = applyRediscoverAlbumFilters(out.RediscoverAlbums, out.MostPlayedTracks)
|
||||||
out.RediscoverArtists = applyRediscoverArtistFilters(out.RediscoverArtists, out.MostPlayedTracks)
|
out.RediscoverArtists = applyRediscoverArtistFilters(out.RediscoverArtists, out.MostPlayedTracks)
|
||||||
|
// You-might-like dedups against what the user already engages with
|
||||||
|
// (Most Played) and against the other Home rows so a tile doesn't
|
||||||
|
// appear twice. Trims to HomeYouMightLikeLimit after dedup.
|
||||||
|
out.YouMightLikeAlbums = applyYouMightLikeAlbumFilters(
|
||||||
|
out.YouMightLikeAlbums, out.MostPlayedTracks, out.RediscoverAlbums)
|
||||||
|
out.YouMightLikeArtists = applyYouMightLikeArtistFilters(
|
||||||
|
out.YouMightLikeArtists, out.MostPlayedTracks, out.RediscoverArtists, out.LastPlayedArtists)
|
||||||
|
// The taste roll-up surfaces top-similar entities, which for a heavy
|
||||||
|
// listener are mostly ones they already play — so the dedup above can
|
||||||
|
// leave the section thin (often a single artist). Top up from the much
|
||||||
|
// larger pool of liked entities, applying the same exclusions so the
|
||||||
|
// fallback neither duplicates a tile nor suggests an actively-played one.
|
||||||
|
if len(out.YouMightLikeArtists) < HomeYouMightLikeLimit {
|
||||||
|
out.YouMightLikeArtists = topUpYouMightLikeArtists(ctx, q, userID, out)
|
||||||
|
}
|
||||||
|
if len(out.YouMightLikeAlbums) < HomeYouMightLikeLimit {
|
||||||
|
out.YouMightLikeAlbums = topUpYouMightLikeAlbums(ctx, q, userID, out)
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// youMightLikeFallbackFetch is the liked-entity pool the fallback draws from.
|
||||||
|
// Generous so the dedup still leaves enough to fill the row.
|
||||||
|
const youMightLikeFallbackFetch = 100
|
||||||
|
|
||||||
|
// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
|
||||||
|
// artists the user has shown affinity for — explicit artist-likes PLUS the
|
||||||
|
// artists of liked albums and liked tracks (most users like albums/tracks far
|
||||||
|
// more than artists, so the broad pool is what actually fills the row).
|
||||||
|
// Excludes what's already shown on Home (the section itself, Rediscover,
|
||||||
|
// Most/Last Played) so the fallback neither duplicates a tile nor suggests an
|
||||||
|
// actively-played artist. Best-effort: a query error leaves the section as-is.
|
||||||
|
func topUpYouMightLikeArtists(
|
||||||
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
||||||
|
) []dbq.ListYouMightLikeArtistsForUserRow {
|
||||||
|
excluded := mostPlayedArtistIDs(out.MostPlayedTracks)
|
||||||
|
for _, r := range out.RediscoverArtists {
|
||||||
|
excluded[r.Artist.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, r := range out.LastPlayedArtists {
|
||||||
|
excluded[r.Artist.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
result := out.YouMightLikeArtists
|
||||||
|
for _, r := range result {
|
||||||
|
excluded[r.Artist.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{
|
||||||
|
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, r := range fb {
|
||||||
|
if _, dup := excluded[r.Artist.ID]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, dbq.ListYouMightLikeArtistsForUserRow(r))
|
||||||
|
if len(result) >= HomeYouMightLikeLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// topUpYouMightLikeAlbums is the album-row counterpart to
|
||||||
|
// topUpYouMightLikeArtists, drawing from the user's liked albums.
|
||||||
|
func topUpYouMightLikeAlbums(
|
||||||
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
||||||
|
) []dbq.ListYouMightLikeAlbumsForUserRow {
|
||||||
|
excluded := mostPlayedAlbumIDs(out.MostPlayedTracks)
|
||||||
|
for _, r := range out.RediscoverAlbums {
|
||||||
|
excluded[r.Album.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
result := out.YouMightLikeAlbums
|
||||||
|
for _, r := range result {
|
||||||
|
excluded[r.Album.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{
|
||||||
|
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
for _, r := range fb {
|
||||||
|
if _, dup := excluded[r.Album.ID]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, dbq.ListYouMightLikeAlbumsForUserRow(r))
|
||||||
|
if len(result) >= HomeYouMightLikeLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyYouMightLikeAlbumFilters drops albums the user actively plays
|
||||||
|
// (Most Played) or that already appear in Rediscover, then trims to
|
||||||
|
// HomeYouMightLikeLimit. Build-time rank order is preserved.
|
||||||
|
func applyYouMightLikeAlbumFilters(
|
||||||
|
rows []dbq.ListYouMightLikeAlbumsForUserRow,
|
||||||
|
mostPlayed []dbq.ListMostPlayedTracksForUserRow,
|
||||||
|
rediscover []dbq.ListRediscoverAlbumsForUserRow,
|
||||||
|
) []dbq.ListYouMightLikeAlbumsForUserRow {
|
||||||
|
excluded := mostPlayedAlbumIDs(mostPlayed)
|
||||||
|
for _, r := range rediscover {
|
||||||
|
excluded[r.Album.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]dbq.ListYouMightLikeAlbumsForUserRow, 0, HomeYouMightLikeLimit)
|
||||||
|
for _, r := range rows {
|
||||||
|
if _, dup := excluded[r.Album.ID]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
if len(out) >= HomeYouMightLikeLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyYouMightLikeArtistFilters drops artists the user actively plays
|
||||||
|
// (Most Played), recently played (Last Played), or that already appear in
|
||||||
|
// Rediscover, then trims to HomeYouMightLikeLimit.
|
||||||
|
func applyYouMightLikeArtistFilters(
|
||||||
|
rows []dbq.ListYouMightLikeArtistsForUserRow,
|
||||||
|
mostPlayed []dbq.ListMostPlayedTracksForUserRow,
|
||||||
|
rediscover []dbq.ListRediscoverArtistsForUserRow,
|
||||||
|
lastPlayed []dbq.ListLastPlayedArtistsForUserRow,
|
||||||
|
) []dbq.ListYouMightLikeArtistsForUserRow {
|
||||||
|
excluded := mostPlayedArtistIDs(mostPlayed)
|
||||||
|
for _, r := range rediscover {
|
||||||
|
excluded[r.Artist.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
for _, r := range lastPlayed {
|
||||||
|
excluded[r.Artist.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
out := make([]dbq.ListYouMightLikeArtistsForUserRow, 0, HomeYouMightLikeLimit)
|
||||||
|
for _, r := range rows {
|
||||||
|
if _, dup := excluded[r.Artist.ID]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
if len(out) >= HomeYouMightLikeLimit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// mostPlayedAlbumIDs returns the set of album IDs whose tracks appear
|
// mostPlayedAlbumIDs returns the set of album IDs whose tracks appear
|
||||||
// in the Most Played row. Used to dedup Rediscover so it doesn't list
|
// in the Most Played row. Used to dedup Rediscover so it doesn't list
|
||||||
// albums the user is actively spinning.
|
// albums the user is actively spinning.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package recommendation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -10,6 +11,77 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// TestHomeData_YouMightLike_FallbackFillsFromLikedArtists: when the taste
|
||||||
|
// roll-up is empty/thin (no you_might_like_artists rows here), the section
|
||||||
|
// fills from the user's liked artists. Rediscover takes its share first; the
|
||||||
|
// You-might-like fallback fills the rest disjointly so no artist is shown
|
||||||
|
// twice. 20 liked artists, none played → Rediscover 10 + You-might-like 10.
|
||||||
|
func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "home-yml-fallback")
|
||||||
|
liked := make(map[pgtype.UUID]bool, 20)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
a := seedArtist(t, pool, fmt.Sprintf("YmlArtist%02d", i), "")
|
||||||
|
likeArtist(t, pool, user.ID, a.ID)
|
||||||
|
liked[a.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := HomeData(context.Background(), pool, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HomeData: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit {
|
||||||
|
t.Fatalf("you-might-like artists = %d, want %d (filled by fallback)",
|
||||||
|
len(got.YouMightLikeArtists), HomeYouMightLikeLimit)
|
||||||
|
}
|
||||||
|
inRediscover := map[pgtype.UUID]bool{}
|
||||||
|
for _, r := range got.RediscoverArtists {
|
||||||
|
inRediscover[r.Artist.ID] = true
|
||||||
|
}
|
||||||
|
for _, r := range got.YouMightLikeArtists {
|
||||||
|
if !liked[r.Artist.ID] {
|
||||||
|
t.Errorf("you-might-like artist %s not from the liked pool", r.Artist.Name)
|
||||||
|
}
|
||||||
|
if inRediscover[r.Artist.ID] {
|
||||||
|
t.Errorf("artist %s shown in both Rediscover and You-might-like", r.Artist.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHomeData_YouMightLike_FallbackFromLikedTrackArtists: the broad fallback
|
||||||
|
// must surface the artists of LIKED TRACKS even with no explicit artist-likes
|
||||||
|
// (most users like tracks/albums, rarely artists). 12 liked tracks by distinct
|
||||||
|
// artists, no artist-likes, no plays → the artist row fills from them.
|
||||||
|
func TestHomeData_YouMightLike_FallbackFromLikedTrackArtists(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "home-yml-trackartists")
|
||||||
|
fromLiked := make(map[pgtype.UUID]bool, 12)
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
a := seedArtist(t, pool, fmt.Sprintf("TrkArtist%02d", i), "")
|
||||||
|
al := seedAlbumForArtist(t, pool, a.ID, fmt.Sprintf("Alb%02d", i))
|
||||||
|
tr := seedTrackOnAlbum(t, pool, al.ID, a.ID, fmt.Sprintf("Trk%02d", i))
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, tr.ID); err != nil {
|
||||||
|
t.Fatalf("like track: %v", err)
|
||||||
|
}
|
||||||
|
fromLiked[a.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := HomeData(context.Background(), pool, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HomeData: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit {
|
||||||
|
t.Fatalf("you-might-like artists = %d, want %d (from liked-track artists)",
|
||||||
|
len(got.YouMightLikeArtists), HomeYouMightLikeLimit)
|
||||||
|
}
|
||||||
|
for _, r := range got.YouMightLikeArtists {
|
||||||
|
if !fromLiked[r.Artist.ID] {
|
||||||
|
t.Errorf("artist %s not from the liked-track pool", r.Artist.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
|
func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
user := seedUser(t, pool, "home-newuser")
|
user := seedUser(t, pool, "home-newuser")
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ type ScoringInputs struct {
|
|||||||
SkipCount int // play_events with was_skipped=true
|
SkipCount int // play_events with was_skipped=true
|
||||||
ContextualMatchScore float64 // [0, 1]; 0 when no signal
|
ContextualMatchScore float64 // [0, 1]; 0 when no signal
|
||||||
SimilarityScore float64 // [0, 1]; 0 when no signal (random fill)
|
SimilarityScore float64 // [0, 1]; 0 when no signal (random fill)
|
||||||
|
// TasteMatchScore is the candidate's fit to the user's learned taste
|
||||||
|
// profile (#796 phase 2), in [-1, +1]: positive draws a track toward the
|
||||||
|
// user's taste, negative reflects passive avoidance, 0 when there's no
|
||||||
|
// profile signal (cold start / artist+tags absent from the profile).
|
||||||
|
TasteMatchScore float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||||
@@ -31,6 +36,7 @@ type ScoringWeights struct {
|
|||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
SimilarityWeight float64
|
SimilarityWeight float64
|
||||||
|
TasteWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score computes the weighted-shuffle score per spec §6:
|
// Score computes the weighted-shuffle score per spec §6:
|
||||||
@@ -41,6 +47,7 @@ type ScoringWeights struct {
|
|||||||
// - skip_ratio * SkipPenalty
|
// - skip_ratio * SkipPenalty
|
||||||
// + contextual_match_score * ContextWeight
|
// + contextual_match_score * ContextWeight
|
||||||
// + similarity_score * SimilarityWeight
|
// + similarity_score * SimilarityWeight
|
||||||
|
// + taste_match_score * TasteWeight
|
||||||
// + small_random_jitter
|
// + small_random_jitter
|
||||||
//
|
//
|
||||||
// Higher score = more likely to surface. rng is a function returning a
|
// Higher score = more likely to surface. rng is a function returning a
|
||||||
@@ -55,6 +62,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64
|
|||||||
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
||||||
s += in.ContextualMatchScore * w.ContextWeight
|
s += in.ContextualMatchScore * w.ContextWeight
|
||||||
s += in.SimilarityScore * w.SimilarityWeight
|
s += in.SimilarityScore * w.SimilarityWeight
|
||||||
|
s += in.TasteMatchScore * w.TasteWeight
|
||||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Taste-match tuning. The taste profile (written by internal/taste) holds
|
||||||
|
// signed, unbounded artist/tag weights; these scales squash them into a
|
||||||
|
// bounded [-1, +1] match via tanh, so one outlier artist can't compress the
|
||||||
|
// rest toward zero (as a per-user max-normalisation would). A weight at the
|
||||||
|
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
|
||||||
|
const (
|
||||||
|
tasteArtistScale = 4.0
|
||||||
|
tasteTagScale = 3.0
|
||||||
|
tasteArtistShare = 0.7
|
||||||
|
tasteTagShare = 0.3
|
||||||
|
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
||||||
|
)
|
||||||
|
|
||||||
|
// TasteProfile is the read-side view of a user's learned taste: signed
|
||||||
|
// weights over artists and genre tags. The zero value (and any unknown
|
||||||
|
// artist/tag) contributes 0, so cold-start users get no taste effect.
|
||||||
|
type TasteProfile struct {
|
||||||
|
artists map[pgtype.UUID]float64
|
||||||
|
tags map[string]float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
|
||||||
|
// tables (written daily by internal/taste). Returns an empty profile with no
|
||||||
|
// error when the user has none.
|
||||||
|
func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (TasteProfile, error) {
|
||||||
|
arts, err := q.ListTasteProfileArtistsForUser(ctx, dbq.ListTasteProfileArtistsForUserParams{
|
||||||
|
UserID: userID, Limit: tasteProfileLimit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return TasteProfile{}, err
|
||||||
|
}
|
||||||
|
tags, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{
|
||||||
|
UserID: userID, Limit: tasteProfileLimit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return TasteProfile{}, err
|
||||||
|
}
|
||||||
|
p := TasteProfile{
|
||||||
|
artists: make(map[pgtype.UUID]float64, len(arts)),
|
||||||
|
tags: make(map[string]float64, len(tags)),
|
||||||
|
}
|
||||||
|
for _, a := range arts {
|
||||||
|
p.artists[a.ArtistID] = a.Weight
|
||||||
|
}
|
||||||
|
for _, t := range tags {
|
||||||
|
p.tags[t.Tag] = t.Weight
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
|
||||||
|
// the artist's taste weight and the average of its genre tags' weights, each
|
||||||
|
// tanh-squashed. Absent artist/tags contribute 0.
|
||||||
|
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
|
||||||
|
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
|
||||||
|
|
||||||
|
var tg float64
|
||||||
|
if genre != nil {
|
||||||
|
tags := splitGenres(*genre)
|
||||||
|
if len(tags) > 0 {
|
||||||
|
var sum float64
|
||||||
|
for _, t := range tags {
|
||||||
|
sum += p.tags[t]
|
||||||
|
}
|
||||||
|
tg = math.Tanh((sum / float64(len(tags))) / tasteTagScale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clampUnit(tasteArtistShare*a + tasteTagShare*tg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// clampUnit constrains x to [-1, 1].
|
||||||
|
func clampUnit(x float64) float64 {
|
||||||
|
if x < -1 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if x > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func uuidN(n byte) pgtype.UUID {
|
||||||
|
return pgtype.UUID{Bytes: [16]byte{15: n}, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func strPtr(s string) *string { return &s }
|
||||||
|
|
||||||
|
func TestTasteProfile_Match(t *testing.T) {
|
||||||
|
loved := uuidN(1)
|
||||||
|
disliked := uuidN(2)
|
||||||
|
unknown := uuidN(3)
|
||||||
|
p := TasteProfile{
|
||||||
|
artists: map[pgtype.UUID]float64{loved: 8.0, disliked: -8.0},
|
||||||
|
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
if m := p.Match(loved, strPtr("Jazz")); m <= 0.5 {
|
||||||
|
t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m)
|
||||||
|
}
|
||||||
|
if m := p.Match(disliked, strPtr("Noise")); m >= -0.5 {
|
||||||
|
t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m)
|
||||||
|
}
|
||||||
|
if m := p.Match(unknown, nil); m != 0 {
|
||||||
|
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
|
||||||
|
}
|
||||||
|
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
||||||
|
// clearly positive.
|
||||||
|
if m := p.Match(loved, strPtr("Unheard")); m <= 0 {
|
||||||
|
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
|
||||||
|
}
|
||||||
|
// Output stays within [-1, 1] even with saturated inputs.
|
||||||
|
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
|
||||||
|
m := p.Match(a, strPtr("Jazz"))
|
||||||
|
if m < -1 || m > 1 {
|
||||||
|
t.Errorf("Match out of [-1,1]: %.3f", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
|
||||||
|
var p TasteProfile // zero value: nil maps
|
||||||
|
if m := p.Match(uuidN(1), strPtr("Jazz")); m != 0 {
|
||||||
|
t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_TasteTermAddsAndSubtracts(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude
|
||||||
|
w := ScoringWeights{TasteWeight: 2.0} // all other weights 0
|
||||||
|
|
||||||
|
pos := Score(ScoringInputs{TasteMatchScore: 1.0}, w, now, zeroJitter)
|
||||||
|
if !almostEq(pos, 2.0) {
|
||||||
|
t.Errorf("positive taste: Score = %.3f, want 2.0", pos)
|
||||||
|
}
|
||||||
|
neg := Score(ScoringInputs{TasteMatchScore: -1.0}, w, now, zeroJitter)
|
||||||
|
if !almostEq(neg, -2.0) {
|
||||||
|
t.Errorf("negative taste: Score = %.3f, want -2.0 (demotes)", neg)
|
||||||
|
}
|
||||||
|
off := Score(ScoringInputs{TasteMatchScore: 1.0}, ScoringWeights{}, now, zeroJitter)
|
||||||
|
if !almostEq(off, 0.0) {
|
||||||
|
t.Errorf("TasteWeight 0: Score = %.3f, want 0 (no effect)", off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func almostEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
|
||||||
|
|
||||||
|
// TestLoadTasteProfile_RoundTrip seeds taste_profile rows and verifies the
|
||||||
|
// reader hydrates them into a profile that scores a matching track positively.
|
||||||
|
func TestLoadTasteProfile_RoundTrip(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
u := seedUser(t, pool, "taste-rt")
|
||||||
|
art := seedArtist(t, pool, "Loved Artist", "")
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)`,
|
||||||
|
u.ID, art.ID, 8.0); err != nil {
|
||||||
|
t.Fatalf("seed taste artist: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)`,
|
||||||
|
u.ID, "Jazz", 6.0); err != nil {
|
||||||
|
t.Fatalf("seed taste tag: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := LoadTasteProfile(ctx, dbq.New(pool), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if m := p.Match(art.ID, strPtr("Jazz")); m <= 0.5 {
|
||||||
|
t.Errorf("round-trip Match = %.3f, want strongly positive", m)
|
||||||
|
}
|
||||||
|
if m := p.Match(uuidN(9), nil); m != 0 {
|
||||||
|
t.Errorf("absent artist Match = %.3f, want 0", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Package taste builds a persistent, decaying per-user taste profile (#796).
|
||||||
|
// engagement.go is the pure core: it turns a single play's completion ratio
|
||||||
|
// into a graded taste signal in [-1, +1], replacing the binary was_skipped
|
||||||
|
// for learning purposes (was_skipped stays as-is for History display).
|
||||||
|
package taste
|
||||||
|
|
||||||
|
import "math"
|
||||||
|
|
||||||
|
// EngagementParams tune the completion→engagement curve. All in [0,1] and
|
||||||
|
// ordered HardSkip < NeutralCompletion < FullCompletion.
|
||||||
|
type EngagementParams struct {
|
||||||
|
// HardSkip: completion at/below which a play is a strong reject (-1).
|
||||||
|
HardSkip float64
|
||||||
|
// NeutralCompletion: completion at which a play is neutral (0). Set low
|
||||||
|
// (~0.30) because people enjoy plenty of tracks they don't finish; only
|
||||||
|
// genuinely-early abandons read as negative.
|
||||||
|
NeutralCompletion float64
|
||||||
|
// FullCompletion: completion at/above which a play is a full positive
|
||||||
|
// (+1).
|
||||||
|
FullCompletion float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultEngagementParams returns the tuned starting curve.
|
||||||
|
func DefaultEngagementParams() EngagementParams {
|
||||||
|
return EngagementParams{HardSkip: 0.05, NeutralCompletion: 0.30, FullCompletion: 0.90}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Engagement maps a play's completion ratio to a taste signal in [-1, +1]
|
||||||
|
// via two linear ramps meeting at the neutral point:
|
||||||
|
//
|
||||||
|
// completion <= HardSkip → -1
|
||||||
|
// HardSkip < c < Neutral → ramp -1 → 0
|
||||||
|
// Neutral <= c < Full → ramp 0 → +1
|
||||||
|
// completion >= Full → +1
|
||||||
|
//
|
||||||
|
// Completion is clamped to [0,1]. Degenerate params (non-increasing
|
||||||
|
// thresholds) collapse the affected ramp to its endpoint rather than
|
||||||
|
// dividing by zero.
|
||||||
|
func Engagement(completion float64, p EngagementParams) float64 {
|
||||||
|
c := clamp01(completion)
|
||||||
|
if c <= p.HardSkip {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if c < p.NeutralCompletion {
|
||||||
|
span := p.NeutralCompletion - p.HardSkip
|
||||||
|
if span <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return -1 + (c-p.HardSkip)/span
|
||||||
|
}
|
||||||
|
if c < p.FullCompletion {
|
||||||
|
span := p.FullCompletion - p.NeutralCompletion
|
||||||
|
if span <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return (c - p.NeutralCompletion) / span
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// decay is the exponential time-decay applied to a play's engagement:
|
||||||
|
// 1.0 at age 0, 0.5 at age == halfLifeDays. Negative ages clamp to 0.
|
||||||
|
func decay(ageDays, halfLifeDays float64) float64 {
|
||||||
|
if ageDays < 0 {
|
||||||
|
ageDays = 0
|
||||||
|
}
|
||||||
|
if halfLifeDays <= 0 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return math.Exp(-math.Ln2 * ageDays / halfLifeDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clamp01(x float64) float64 {
|
||||||
|
if x < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if x > 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package taste
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func almostEqual(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
|
||||||
|
|
||||||
|
func TestEngagement_Curve(t *testing.T) {
|
||||||
|
p := DefaultEngagementParams() // HardSkip 0.05, Neutral 0.30, Full 0.90
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
completion float64
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"instant skip", 0.00, -1},
|
||||||
|
{"at hard-skip boundary", 0.05, -1},
|
||||||
|
{"neg ramp midpoint", 0.175, -0.5}, // (0.175-0.05)/(0.30-0.05)=0.5 → -0.5
|
||||||
|
{"neutral point", 0.30, 0},
|
||||||
|
{"pos ramp midpoint", 0.60, 0.5}, // (0.60-0.30)/(0.90-0.30)=0.5
|
||||||
|
{"at full boundary", 0.90, 1},
|
||||||
|
{"complete", 1.00, 1},
|
||||||
|
{"clamp above 1", 1.50, 1},
|
||||||
|
{"clamp below 0", -0.5, -1},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := Engagement(c.completion, p); !almostEqual(got, c.want) {
|
||||||
|
t.Errorf("%s: Engagement(%.3f) = %.4f, want %.4f", c.name, c.completion, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEngagement_MonotonicNonDecreasing(t *testing.T) {
|
||||||
|
p := DefaultEngagementParams()
|
||||||
|
prev := math.Inf(-1)
|
||||||
|
for i := 0; i <= 100; i++ {
|
||||||
|
c := float64(i) / 100
|
||||||
|
got := Engagement(c, p)
|
||||||
|
if got < prev-1e-9 {
|
||||||
|
t.Fatalf("engagement decreased at completion %.2f: %.4f < %.4f", c, got, prev)
|
||||||
|
}
|
||||||
|
prev = got
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEngagement_DegenerateParamsStayInRange(t *testing.T) {
|
||||||
|
// Non-increasing thresholds must collapse ramps (no divide-by-zero) and
|
||||||
|
// still produce a value in [-1, 1].
|
||||||
|
p := EngagementParams{HardSkip: 0.5, NeutralCompletion: 0.5, FullCompletion: 0.5}
|
||||||
|
for _, c := range []float64{0, 0.25, 0.5, 0.75, 1} {
|
||||||
|
got := Engagement(c, p)
|
||||||
|
if got < -1 || got > 1 {
|
||||||
|
t.Errorf("Engagement(%.2f) = %.3f out of [-1,1]", c, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecay_HalfLife(t *testing.T) {
|
||||||
|
if got := decay(0, 75); !almostEqual(got, 1) {
|
||||||
|
t.Errorf("decay(0) = %.4f, want 1", got)
|
||||||
|
}
|
||||||
|
if got := decay(75, 75); !almostEqual(got, 0.5) {
|
||||||
|
t.Errorf("decay(halflife) = %.4f, want 0.5", got)
|
||||||
|
}
|
||||||
|
if got := decay(150, 75); !almostEqual(got, 0.25) {
|
||||||
|
t.Errorf("decay(2*halflife) = %.4f, want 0.25", got)
|
||||||
|
}
|
||||||
|
if got := decay(-5, 75); !almostEqual(got, 1) {
|
||||||
|
t.Errorf("decay(negative age) = %.4f, want 1 (clamped)", got)
|
||||||
|
}
|
||||||
|
if got := decay(10, 0); !almostEqual(got, 1) {
|
||||||
|
t.Errorf("decay(halflife=0) = %.4f, want 1 (guarded)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
// profile.go assembles the per-user taste profile from graded play
|
||||||
|
// engagement (decayed over time) plus explicit-like bonuses, and
|
||||||
|
// atomic-replaces the taste_profile_* tables. Phase 1 only builds the
|
||||||
|
// object; candidate sourcing + scoring consume it in phase 2.
|
||||||
|
package taste
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
|
||||||
|
// starting values; wiring these into the server RecommendationConfig is a
|
||||||
|
// later follow-up (phase 1 uses the defaults).
|
||||||
|
type Config struct {
|
||||||
|
Engagement EngagementParams
|
||||||
|
|
||||||
|
// HalfLifeDays: a play's engagement halves every this-many days, so the
|
||||||
|
// profile tracks drift (recent behaviour dominates). WindowDays bounds
|
||||||
|
// the plays fetched — beyond ~3 half-lives the decayed contribution is
|
||||||
|
// negligible, so older plays are skipped for cost, not correctness.
|
||||||
|
HalfLifeDays float64
|
||||||
|
WindowDays float64
|
||||||
|
|
||||||
|
// Explicit-like bonuses. Likes stay the strongest single positive.
|
||||||
|
ArtistLikeBonus float64
|
||||||
|
TrackLikeBonus float64
|
||||||
|
TagLikeBonus float64
|
||||||
|
|
||||||
|
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
|
||||||
|
// go. Aggregation already protects an artist the user likes (one skip
|
||||||
|
// nets out against many good plays); the floor additionally bounds the
|
||||||
|
// magnitude so a heavily-skipped entity can't dominate as an extreme
|
||||||
|
// negative.
|
||||||
|
ArtistFloor float64
|
||||||
|
TagFloor float64
|
||||||
|
|
||||||
|
// WeightEpsilon: entities with |weight| below this are dropped as noise.
|
||||||
|
WeightEpsilon float64
|
||||||
|
|
||||||
|
// Size caps (guard rails for pathological libraries). Persist the top
|
||||||
|
// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags.
|
||||||
|
MaxArtists int
|
||||||
|
MaxNegArtists int
|
||||||
|
MaxTags int
|
||||||
|
MaxNegTags int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfig returns the tuned starting configuration.
|
||||||
|
func DefaultConfig() Config {
|
||||||
|
return Config{
|
||||||
|
Engagement: DefaultEngagementParams(),
|
||||||
|
HalfLifeDays: 75,
|
||||||
|
WindowDays: 270,
|
||||||
|
ArtistLikeBonus: 3.0,
|
||||||
|
TrackLikeBonus: 1.0,
|
||||||
|
TagLikeBonus: 0.5,
|
||||||
|
ArtistFloor: -3.0,
|
||||||
|
TagFloor: -3.0,
|
||||||
|
WeightEpsilon: 0.05,
|
||||||
|
MaxArtists: 500,
|
||||||
|
MaxNegArtists: 150,
|
||||||
|
MaxTags: 200,
|
||||||
|
MaxNegTags: 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuildTasteProfile recomputes and atomic-replaces the user's taste profile.
|
||||||
|
// Reads the decay-windowed play engagement + explicit likes, accumulates
|
||||||
|
// signed artist/tag weights, applies floor damping + size caps, and persists.
|
||||||
|
// Self-contained transaction for the replace. Returns an error only on a
|
||||||
|
// fatal DB failure; the daily caller logs and proceeds to playlist building
|
||||||
|
// regardless (best-effort).
|
||||||
|
//
|
||||||
|
// The decay reference time is the database clock (age is computed in SQL via
|
||||||
|
// now()), so no Go-side timestamp is threaded here; a phase-3 context model
|
||||||
|
// that needs a pinned reference time would add one.
|
||||||
|
func BuildTasteProfile(
|
||||||
|
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
|
||||||
|
userID pgtype.UUID, cfg Config,
|
||||||
|
) error {
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
artistW, tagW, err := accumulate(ctx, q, userID, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applyFloor(artistW, cfg.ArtistFloor)
|
||||||
|
applyFloor(tagW, cfg.TagFloor)
|
||||||
|
|
||||||
|
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
|
||||||
|
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags,
|
||||||
|
func(a, b string) bool { return a < b })
|
||||||
|
|
||||||
|
if err := persist(ctx, pool, userID, artists, tags); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
logger.Debug("taste: profile rebuilt",
|
||||||
|
"user_id", uuidString(userID), "artists", len(artists), "tags", len(tags))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// accumulate sums decayed play engagement and like bonuses into artist/tag
|
||||||
|
// weight maps.
|
||||||
|
func accumulate(
|
||||||
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
|
||||||
|
) (map[pgtype.UUID]float64, map[string]float64, error) {
|
||||||
|
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
|
||||||
|
UserID: userID,
|
||||||
|
Column2: cfg.WindowDays,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
|
||||||
|
}
|
||||||
|
artistW := make(map[pgtype.UUID]float64)
|
||||||
|
tagW := make(map[string]float64)
|
||||||
|
for _, p := range plays {
|
||||||
|
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
|
||||||
|
artistW[p.ArtistID] += e
|
||||||
|
for _, tag := range splitGenres(p.Genre) {
|
||||||
|
tagW[tag] += e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||||
|
}
|
||||||
|
for _, lt := range likedTracks {
|
||||||
|
artistW[lt.ArtistID] += cfg.TrackLikeBonus
|
||||||
|
for _, tag := range splitGenres(lt.Genre) {
|
||||||
|
tagW[tag] += cfg.TagLikeBonus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
|
||||||
|
}
|
||||||
|
for _, aid := range likedArtists {
|
||||||
|
artistW[aid] += cfg.ArtistLikeBonus
|
||||||
|
}
|
||||||
|
return artistW, tagW, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// persist atomic-replaces the user's profile rows inside one transaction.
|
||||||
|
func persist(
|
||||||
|
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||||
|
artists []weighted[pgtype.UUID], tags []weighted[string],
|
||||||
|
) error {
|
||||||
|
tx, err := pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("taste: begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
qtx := dbq.New(tx)
|
||||||
|
|
||||||
|
if err := qtx.DeleteTasteProfileArtistsForUser(ctx, userID); err != nil {
|
||||||
|
return fmt.Errorf("taste: delete artists: %w", err)
|
||||||
|
}
|
||||||
|
for _, a := range artists {
|
||||||
|
if err := qtx.InsertTasteProfileArtist(ctx, dbq.InsertTasteProfileArtistParams{
|
||||||
|
UserID: userID, ArtistID: a.Key, Weight: a.Weight,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("taste: insert artist: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := qtx.DeleteTasteProfileTagsForUser(ctx, userID); err != nil {
|
||||||
|
return fmt.Errorf("taste: delete tags: %w", err)
|
||||||
|
}
|
||||||
|
for _, t := range tags {
|
||||||
|
if err := qtx.InsertTasteProfileTag(ctx, dbq.InsertTasteProfileTagParams{
|
||||||
|
UserID: userID, Tag: t.Key, Weight: t.Weight,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("taste: insert tag: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("taste: commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// weighted is a key with its signed taste weight.
|
||||||
|
type weighted[K comparable] struct {
|
||||||
|
Key K
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// rankWeighted drops near-zero entries, sorts by weight DESC (ties broken by
|
||||||
|
// keyLess for determinism), and caps to the top topN plus the most-negative
|
||||||
|
// botN so both ends of the signal survive without unbounded table growth.
|
||||||
|
func rankWeighted[K comparable](
|
||||||
|
m map[K]float64, eps float64, topN, botN int, keyLess func(a, b K) bool,
|
||||||
|
) []weighted[K] {
|
||||||
|
all := make([]weighted[K], 0, len(m))
|
||||||
|
for k, w := range m {
|
||||||
|
if w >= eps || w <= -eps {
|
||||||
|
all = append(all, weighted[K]{Key: k, Weight: w})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(all, func(i, j int) bool {
|
||||||
|
if all[i].Weight != all[j].Weight {
|
||||||
|
return all[i].Weight > all[j].Weight
|
||||||
|
}
|
||||||
|
return keyLess(all[i].Key, all[j].Key)
|
||||||
|
})
|
||||||
|
if topN+botN >= len(all) {
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
out := make([]weighted[K], 0, topN+botN)
|
||||||
|
out = append(out, all[:topN]...)
|
||||||
|
out = append(out, all[len(all)-botN:]...)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyFloor clamps every weight in m up to floor (no entity goes more
|
||||||
|
// negative than the floor).
|
||||||
|
func applyFloor[K comparable](m map[K]float64, floor float64) {
|
||||||
|
for k, w := range m {
|
||||||
|
if w < floor {
|
||||||
|
m[k] = floor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uuidLess orders two UUIDs by raw bytes (deterministic tie-break).
|
||||||
|
func uuidLess(a, b pgtype.UUID) bool {
|
||||||
|
return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// uuidString renders a pgtype.UUID canonically for log lines.
|
||||||
|
func uuidString(u pgtype.UUID) string {
|
||||||
|
if !u.Valid {
|
||||||
|
return "<nil>"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x",
|
||||||
|
u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitGenres splits a track's denormalized genre string on the common
|
||||||
|
// multi-genre delimiters, trimming whitespace and dropping empties. Mirrors
|
||||||
|
// the recommendation package's splitter (kept local to avoid a dependency).
|
||||||
|
func splitGenres(genre *string) []string {
|
||||||
|
if genre == nil || *genre == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.FieldsFunc(*genre, func(r rune) bool {
|
||||||
|
return r == ';' || r == ','
|
||||||
|
})
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package taste
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
var seedCounter uint64
|
||||||
|
|
||||||
|
func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||||
|
|
||||||
|
func newPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test in -short mode")
|
||||||
|
}
|
||||||
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
if err := db.Migrate(dsn, discardLogger()); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
dbtest.ResetDB(t, pool)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
||||||
|
t.Helper()
|
||||||
|
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||||
|
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
||||||
|
ApiToken: name + "-token", IsAdmin: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed user: %v", err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedArtist(t *testing.T, pool *pgxpool.Pool, name string) dbq.Artist {
|
||||||
|
t.Helper()
|
||||||
|
a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||||||
|
Name: name, SortName: name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed artist: %v", err)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedTrack creates an album + track under the given artist with a genre.
|
||||||
|
func seedTrack(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, genre string) dbq.Track {
|
||||||
|
t.Helper()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
n := atomic.AddUint64(&seedCounter, 1)
|
||||||
|
al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||||
|
Title: fmt.Sprintf("Album %d", n), SortTitle: fmt.Sprintf("Album %d", n), ArtistID: artistID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed album: %v", err)
|
||||||
|
}
|
||||||
|
g := genre
|
||||||
|
tk, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||||
|
Title: fmt.Sprintf("Track %d", n), AlbumID: al.ID, ArtistID: artistID,
|
||||||
|
DurationMs: 200000, FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("t%d.mp3", n)),
|
||||||
|
FileSize: 100, FileFormat: "mp3", Genre: &g,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed track: %v", err)
|
||||||
|
}
|
||||||
|
return tk
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedPlay inserts a play with an explicit completion_ratio at startedAt.
|
||||||
|
func seedPlay(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, completion float64) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := pool.Exec(context.Background(), `
|
||||||
|
WITH s AS (
|
||||||
|
INSERT INTO play_sessions (user_id, started_at, last_event_at)
|
||||||
|
VALUES ($1, $3, $3) RETURNING id
|
||||||
|
)
|
||||||
|
INSERT INTO play_events (user_id, track_id, session_id, started_at, completion_ratio, was_skipped)
|
||||||
|
SELECT $1, $2, s.id, $3, $4, false FROM s
|
||||||
|
`, userID, trackID, startedAt, completion)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed play: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func artistWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) (float64, bool) {
|
||||||
|
t.Helper()
|
||||||
|
var w float64
|
||||||
|
err := pool.QueryRow(context.Background(),
|
||||||
|
`SELECT weight FROM taste_profile_artists WHERE user_id = $1 AND artist_id = $2`,
|
||||||
|
userID, artistID).Scan(&w)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("artist weight: %v", err)
|
||||||
|
}
|
||||||
|
return w, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagWeight(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string) (float64, bool) {
|
||||||
|
t.Helper()
|
||||||
|
var w float64
|
||||||
|
err := pool.QueryRow(context.Background(),
|
||||||
|
`SELECT weight FROM taste_profile_tags WHERE user_id = $1 AND tag = $2`,
|
||||||
|
userID, tag).Scan(&w)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("tag weight: %v", err)
|
||||||
|
}
|
||||||
|
return w, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildTasteProfile_PositiveVsNegative: a completed-track artist earns a
|
||||||
|
// positive weight; an early-skipped artist earns a negative one. Genre tags
|
||||||
|
// follow the same sign.
|
||||||
|
func TestBuildTasteProfile_PositiveVsNegative(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
u := seedUser(t, pool, "tp1")
|
||||||
|
|
||||||
|
loved := seedArtist(t, pool, "Loved")
|
||||||
|
lovedTrack := seedTrack(t, pool, loved.ID, "Rock")
|
||||||
|
skipped := seedArtist(t, pool, "Skipped")
|
||||||
|
skippedTrack := seedTrack(t, pool, skipped.ID, "Noise")
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
seedPlay(t, pool, u.ID, lovedTrack.ID, now.Add(-time.Duration(i+1)*time.Hour), 1.0)
|
||||||
|
seedPlay(t, pool, u.ID, skippedTrack.ID, now.Add(-time.Duration(i+1)*time.Hour), 0.02)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil {
|
||||||
|
t.Fatalf("build: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lw, ok := artistWeight(t, pool, u.ID, loved.ID)
|
||||||
|
if !ok || lw <= 0 {
|
||||||
|
t.Errorf("loved artist weight = %.3f (present=%v), want > 0", lw, ok)
|
||||||
|
}
|
||||||
|
sw, ok := artistWeight(t, pool, u.ID, skipped.ID)
|
||||||
|
if !ok || sw >= 0 {
|
||||||
|
t.Errorf("skipped artist weight = %.3f (present=%v), want < 0", sw, ok)
|
||||||
|
}
|
||||||
|
if rw, ok := tagWeight(t, pool, u.ID, "Rock"); !ok || rw <= 0 {
|
||||||
|
t.Errorf("Rock tag weight = %.3f (present=%v), want > 0", rw, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildTasteProfile_AggregationProtectsArtist: one early-skip among many
|
||||||
|
// completed plays must NOT make a loved artist net-negative (the user's
|
||||||
|
// concern that a track-dislike shouldn't tank the artist).
|
||||||
|
func TestBuildTasteProfile_AggregationProtectsArtist(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
u := seedUser(t, pool, "tp2")
|
||||||
|
fav := seedArtist(t, pool, "Fav")
|
||||||
|
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
tk := seedTrack(t, pool, fav.ID, "Indie")
|
||||||
|
seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Duration(i+1)*time.Hour), 1.0)
|
||||||
|
seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Duration(i+5)*time.Hour), 0.95)
|
||||||
|
}
|
||||||
|
// One early-skipped track by the same artist.
|
||||||
|
skipTrack := seedTrack(t, pool, fav.ID, "Indie")
|
||||||
|
seedPlay(t, pool, u.ID, skipTrack.ID, now.Add(-2*time.Hour), 0.02)
|
||||||
|
|
||||||
|
if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil {
|
||||||
|
t.Fatalf("build: %v", err)
|
||||||
|
}
|
||||||
|
w, ok := artistWeight(t, pool, u.ID, fav.ID)
|
||||||
|
if !ok || w <= 0 {
|
||||||
|
t.Errorf("fav artist weight = %.3f (present=%v), want > 0 despite one skip", w, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildTasteProfile_ArtistLikeBonus: an explicitly-liked artist with no
|
||||||
|
// plays still earns a positive weight from the like bonus.
|
||||||
|
func TestBuildTasteProfile_ArtistLikeBonus(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
u := seedUser(t, pool, "tp3")
|
||||||
|
liked := seedArtist(t, pool, "LikedOnly")
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`,
|
||||||
|
u.ID, liked.ID); err != nil {
|
||||||
|
t.Fatalf("seed artist like: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil {
|
||||||
|
t.Fatalf("build: %v", err)
|
||||||
|
}
|
||||||
|
w, ok := artistWeight(t, pool, u.ID, liked.ID)
|
||||||
|
if !ok || w <= 0 {
|
||||||
|
t.Errorf("liked-only artist weight = %.3f (present=%v), want > 0 from like bonus", w, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildTasteProfile_AtomicReplace: a second build replaces, not appends.
|
||||||
|
func TestBuildTasteProfile_AtomicReplace(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
u := seedUser(t, pool, "tp4")
|
||||||
|
a := seedArtist(t, pool, "A")
|
||||||
|
tk := seedTrack(t, pool, a.ID, "Rock")
|
||||||
|
seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Hour), 1.0)
|
||||||
|
|
||||||
|
count := func() int {
|
||||||
|
var n int
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM taste_profile_artists WHERE user_id = $1`, u.ID).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if err := BuildTasteProfile(ctx, pool, discardLogger(), u.ID, DefaultConfig()); err != nil {
|
||||||
|
t.Fatalf("build %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := count(); got != 1 {
|
||||||
|
t.Errorf("artist rows after two builds = %d, want 1 (atomic replace)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package taste
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
func strLess(a, b string) bool { return a < b }
|
||||||
|
|
||||||
|
func TestRankWeighted_DropsEpsilonAndSortsDesc(t *testing.T) {
|
||||||
|
m := map[string]float64{"a": 3.0, "b": 1.0, "tiny": 0.01, "d": -2.0}
|
||||||
|
got := rankWeighted(m, 0.05, 10, 10, strLess)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len = %d, want 3 (tiny dropped by epsilon)", len(got))
|
||||||
|
}
|
||||||
|
wantOrder := []string{"a", "b", "d"} // 3, 1, -2 descending
|
||||||
|
for i, w := range wantOrder {
|
||||||
|
if got[i].Key != w {
|
||||||
|
t.Errorf("pos %d = %q, want %q", i, got[i].Key, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankWeighted_CapsTopAndBottom(t *testing.T) {
|
||||||
|
m := map[string]float64{"e5": 5, "e4": 4, "e3": 3, "e2": 2, "e1": 1}
|
||||||
|
got := rankWeighted(m, 0.0, 1, 1, strLess) // top 1 + bottom 1
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].Key != "e5" || got[1].Key != "e1" {
|
||||||
|
t.Errorf("got [%s,%s], want [e5,e1]", got[0].Key, got[1].Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRankWeighted_DeterministicTieBreak(t *testing.T) {
|
||||||
|
m := map[string]float64{"z": 1.0, "a": 1.0, "m": 1.0}
|
||||||
|
got := rankWeighted(m, 0.0, 10, 10, strLess)
|
||||||
|
// Equal weights → ordered by keyLess (string asc): a, m, z.
|
||||||
|
want := []string{"a", "m", "z"}
|
||||||
|
for i, w := range want {
|
||||||
|
if got[i].Key != w {
|
||||||
|
t.Errorf("tie-break pos %d = %q, want %q", i, got[i].Key, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyFloor_ClampsNegatives(t *testing.T) {
|
||||||
|
m := map[string]float64{"a": -5, "b": 2, "c": -1}
|
||||||
|
applyFloor(m, -3)
|
||||||
|
if m["a"] != -3 {
|
||||||
|
t.Errorf("a = %.1f, want -3 (floored)", m["a"])
|
||||||
|
}
|
||||||
|
if m["b"] != 2 {
|
||||||
|
t.Errorf("b = %.1f, want 2 (untouched)", m["b"])
|
||||||
|
}
|
||||||
|
if m["c"] != -1 {
|
||||||
|
t.Errorf("c = %.1f, want -1 (above floor)", m["c"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitGenres(t *testing.T) {
|
||||||
|
mk := func(s string) *string { return &s }
|
||||||
|
cases := []struct {
|
||||||
|
in *string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{nil, nil},
|
||||||
|
{mk(""), nil},
|
||||||
|
{mk("Rock"), []string{"Rock"}},
|
||||||
|
{mk(" Rock "), []string{"Rock"}},
|
||||||
|
{mk("Rock; Pop, Jazz"), []string{"Rock", "Pop", "Jazz"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := splitGenres(c.in)
|
||||||
|
if len(got) != len(c.want) {
|
||||||
|
t.Errorf("splitGenres(%v) len = %d, want %d", c.in, len(got), len(c.want))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != c.want[i] {
|
||||||
|
t.Errorf("splitGenres(%v)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUuidLess_Orders(t *testing.T) {
|
||||||
|
a := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
|
b := pgtype.UUID{Bytes: [16]byte{2}, Valid: true}
|
||||||
|
if !uuidLess(a, b) || uuidLess(b, a) {
|
||||||
|
t.Error("uuidLess byte ordering wrong")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended",
|
||||||
|
":semanticCommits"
|
||||||
|
],
|
||||||
|
"baseBranches": ["dev"],
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"schedule": ["every weekend"],
|
||||||
|
"prHourlyLimit": 2,
|
||||||
|
"prConcurrentLimit": 8,
|
||||||
|
"ignorePaths": [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/vendor/**",
|
||||||
|
"flutter_client/**"
|
||||||
|
],
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": ["before 5am on the first day of the month"]
|
||||||
|
},
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Auto-merge patch/minor/digest/pin bumps once CI is green",
|
||||||
|
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Hold all major bumps for manual approval via the dependency dashboard",
|
||||||
|
"matchUpdateTypes": ["major"],
|
||||||
|
"automerge": false,
|
||||||
|
"dependencyDashboardApproval": true,
|
||||||
|
"addLabels": ["deps", "deps:major"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Go module updates into one PR",
|
||||||
|
"matchManagers": ["gomod"],
|
||||||
|
"groupName": "go modules"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group CI workflow action bumps",
|
||||||
|
"matchManagers": ["github-actions"],
|
||||||
|
"groupName": "ci actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group Docker base-image bumps (Dockerfile + compose)",
|
||||||
|
"matchManagers": ["dockerfile", "docker-compose"],
|
||||||
|
"groupName": "docker images"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group the Android Gradle/Kotlin toolchain",
|
||||||
|
"matchManagers": ["gradle", "gradle-wrapper"],
|
||||||
|
"groupName": "android gradle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Group web npm non-major bumps",
|
||||||
|
"matchManagers": ["npm"],
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"groupName": "web npm (non-major)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
||||||
import { removeTrack } from './tracks';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function stubFetch(status: number, body: unknown, init: Partial<Response> = {}) {
|
|
||||||
const res = new Response(
|
|
||||||
body === null ? null : JSON.stringify(body),
|
|
||||||
{ status, headers: { 'Content-Type': 'application/json' }, ...init }
|
|
||||||
);
|
|
||||||
const spy = vi.fn().mockResolvedValue(res);
|
|
||||||
vi.stubGlobal('fetch', spy);
|
|
||||||
return spy;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('removeTrack', () => {
|
|
||||||
test('DELETEs /api/admin/tracks/:id with no query string when unmonitor unset', async () => {
|
|
||||||
const spy = stubFetch(200, {
|
|
||||||
deleted_track_id: 't-1',
|
|
||||||
deleted_album_id: 'al-1'
|
|
||||||
});
|
|
||||||
|
|
||||||
const r = await removeTrack('t-1');
|
|
||||||
expect(r.deleted_track_id).toBe('t-1');
|
|
||||||
expect(r.deleted_album_id).toBe('al-1');
|
|
||||||
expect(r.deleted_artist_id).toBeUndefined();
|
|
||||||
expect(r.lidarr_unmonitor_failed).toBeUndefined();
|
|
||||||
|
|
||||||
const call = spy.mock.calls[0];
|
|
||||||
expect(call[0]).toBe('/api/admin/tracks/t-1');
|
|
||||||
expect((call[1] as RequestInit).method).toBe('DELETE');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('appends ?unmonitor=true when option set', async () => {
|
|
||||||
const spy = stubFetch(200, {
|
|
||||||
deleted_track_id: 't-1',
|
|
||||||
lidarr_unmonitor_failed: true
|
|
||||||
});
|
|
||||||
|
|
||||||
const r = await removeTrack('t-1', { unmonitor: true });
|
|
||||||
expect(r.lidarr_unmonitor_failed).toBe(true);
|
|
||||||
|
|
||||||
const call = spy.mock.calls[0];
|
|
||||||
expect(call[0]).toBe('/api/admin/tracks/t-1?unmonitor=true');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('omits the query string when unmonitor: false', async () => {
|
|
||||||
const spy = stubFetch(200, { deleted_track_id: 't-1' });
|
|
||||||
|
|
||||||
await removeTrack('t-1', { unmonitor: false });
|
|
||||||
const call = spy.mock.calls[0];
|
|
||||||
expect(call[0]).toBe('/api/admin/tracks/t-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('surfaces not_found as ApiError', async () => {
|
|
||||||
stubFetch(404, { error: 'not_found' });
|
|
||||||
|
|
||||||
await expect(removeTrack('t-1')).rejects.toMatchObject({
|
|
||||||
code: 'not_found'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { apiFetch } from '$lib/api/client';
|
|
||||||
|
|
||||||
export type RemoveTrackResult = {
|
|
||||||
deleted_track_id: string;
|
|
||||||
deleted_album_id?: string;
|
|
||||||
deleted_artist_id?: string;
|
|
||||||
/** Only present (always `true` when set) when the operator requested
|
|
||||||
* unmonitor=true AND the Lidarr unmonitor call failed. The destructive
|
|
||||||
* file + DB delete already succeeded — surface a follow-up toast so
|
|
||||||
* the operator knows to manually unmonitor in Lidarr. */
|
|
||||||
lidarr_unmonitor_failed?: true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RemoveTrackOptions = {
|
|
||||||
/** When true, after the file + DB delete the server calls
|
|
||||||
* Lidarr.UnmonitorTrack so Lidarr won't search for a replacement.
|
|
||||||
* When false / omitted, no Lidarr call — Lidarr's monitoring will
|
|
||||||
* re-import on next scan, which is the operator's "find a replacement"
|
|
||||||
* path. */
|
|
||||||
unmonitor?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/admin/tracks/{id} — admin-only. Deletes the file from
|
|
||||||
* disk and the DB row, cascading album/artist tidy-up. Optionally
|
|
||||||
* unmonitors the track in Lidarr.
|
|
||||||
*
|
|
||||||
* On success returns the deleted track id plus optional album/artist
|
|
||||||
* ids when their parent row was tidied up by the server-side cascade.
|
|
||||||
* Caller is responsible for invalidating any TanStack Query keys the
|
|
||||||
* deleted entities backed.
|
|
||||||
*/
|
|
||||||
export async function removeTrack(
|
|
||||||
id: string,
|
|
||||||
options: RemoveTrackOptions = {}
|
|
||||||
): Promise<RemoveTrackResult> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (options.unmonitor) params.set('unmonitor', 'true');
|
|
||||||
const qs = params.toString();
|
|
||||||
const url = `/api/admin/tracks/${encodeURIComponent(id)}${qs ? '?' + qs : ''}`;
|
|
||||||
return (await apiFetch(url, { method: 'DELETE' })) as RemoveTrackResult;
|
|
||||||
}
|
|
||||||
@@ -18,7 +18,9 @@ describe('home client', () => {
|
|||||||
rediscover_albums: [],
|
rediscover_albums: [],
|
||||||
rediscover_artists: [],
|
rediscover_artists: [],
|
||||||
most_played_tracks: [],
|
most_played_tracks: [],
|
||||||
last_played_artists: []
|
last_played_artists: [],
|
||||||
|
you_might_like_albums: [],
|
||||||
|
you_might_like_artists: []
|
||||||
};
|
};
|
||||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture);
|
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture);
|
||||||
const got = await listHome();
|
const got = await listHome();
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
// Mirrors internal/api/me_recommendation_metrics.go.
|
||||||
|
export type RecommendationMetric = {
|
||||||
|
source: string;
|
||||||
|
plays: number;
|
||||||
|
skips: number;
|
||||||
|
skip_rate: number;
|
||||||
|
avg_completion: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecommendationMetrics = {
|
||||||
|
window_days: number;
|
||||||
|
sources: RecommendationMetric[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRecommendationMetrics(): Promise<RecommendationMetrics> {
|
||||||
|
return api.get<RecommendationMetrics>('/api/me/recommendation-metrics');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REC_METRICS_QUERY_KEY = ['settings', 'recommendation-metrics'] as const;
|
||||||
|
|
||||||
|
export function createRecommendationMetricsQuery() {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: REC_METRICS_QUERY_KEY,
|
||||||
|
queryFn: getRecommendationMetrics,
|
||||||
|
staleTime: 60_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Friendly labels for the system-playlist source keys (play_events.source).
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
for_you: 'For You',
|
||||||
|
discover: 'Discover',
|
||||||
|
deep_cuts: 'Deep cuts',
|
||||||
|
rediscover: 'Rediscover',
|
||||||
|
new_for_you: 'New for you',
|
||||||
|
on_this_day: 'On this day',
|
||||||
|
first_listens: 'First listens',
|
||||||
|
songs_like_artist: 'Songs like…'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sourceLabel(source: string): string {
|
||||||
|
return SOURCE_LABELS[source] ?? source;
|
||||||
|
}
|
||||||
@@ -342,4 +342,6 @@ export type HomePayload = {
|
|||||||
rediscover_artists: ArtistRef[];
|
rediscover_artists: ArtistRef[];
|
||||||
most_played_tracks: TrackRef[];
|
most_played_tracks: TrackRef[];
|
||||||
last_played_artists: ArtistRef[];
|
last_played_artists: ArtistRef[];
|
||||||
|
you_might_like_albums: AlbumRef[];
|
||||||
|
you_might_like_artists: ArtistRef[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
||||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
||||||
import { Play } from 'lucide-svelte';
|
import { Play } from 'lucide-svelte';
|
||||||
import AlbumMenu from './AlbumMenu.svelte';
|
import AlbumMenu from './AlbumMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
|
|
||||||
let { album }: { album: AlbumRef } = $props();
|
let { album }: { album: AlbumRef } = $props();
|
||||||
|
|
||||||
function onImgError(e: Event) {
|
|
||||||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onAddClick(e: MouseEvent) {
|
async function onAddClick(e: MouseEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -38,12 +34,9 @@
|
|||||||
shadow-sm transition-all duration-150
|
shadow-sm transition-all duration-150
|
||||||
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
||||||
>
|
>
|
||||||
<img
|
<Cover
|
||||||
src={album.cover_url}
|
src={album.cover_url}
|
||||||
alt=""
|
class="transition-transform group-hover:scale-[1.03]"
|
||||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
|
||||||
loading="lazy"
|
|
||||||
onerror={onImgError}
|
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
||||||
import { api } from '$lib/api/client';
|
import { api } from '$lib/api/client';
|
||||||
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
|
||||||
import { Disc3, Play } from 'lucide-svelte';
|
import { Play } from 'lucide-svelte';
|
||||||
import ArtistMenu from './ArtistMenu.svelte';
|
import ArtistMenu from './ArtistMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import CardActionCluster from './CardActionCluster.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
import { listArtistTracks } from '$lib/api/artists';
|
import { listArtistTracks } from '$lib/api/artists';
|
||||||
|
|
||||||
let { artist }: { artist: ArtistRef } = $props();
|
let { artist }: { artist: ArtistRef } = $props();
|
||||||
@@ -48,18 +49,12 @@
|
|||||||
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
|
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
|
||||||
{#if artist.cover_url}
|
<Cover
|
||||||
<img
|
src={artist.cover_url}
|
||||||
src={artist.cover_url}
|
shape="round"
|
||||||
alt=""
|
fallback="artist"
|
||||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
class="transition-transform group-hover:scale-[1.03]"
|
||||||
loading="lazy"
|
/>
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="flex h-full w-full items-center justify-center">
|
|
||||||
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Play ${artist.name}`}
|
aria-label={`Play ${artist.name}`}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { Plus } from 'lucide-svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
import { coverUrl } from '$lib/media/covers';
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import CardActionCluster from './CardActionCluster.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
import Cover from './Cover.svelte';
|
||||||
|
|
||||||
// Horizontal compact track row — cover thumb on the left, title +
|
// Horizontal compact track row — cover thumb on the left, title +
|
||||||
// artist on the right. Mirrors Android's CompactTrackTile so the
|
// artist on the right. Mirrors Android's CompactTrackTile so the
|
||||||
@@ -22,10 +24,6 @@
|
|||||||
|
|
||||||
const cover = $derived(coverUrl(track.album_id));
|
const cover = $derived(coverUrl(track.album_id));
|
||||||
|
|
||||||
function onImgError(e: Event) {
|
|
||||||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onClick() {
|
function onClick() {
|
||||||
playQueue(sectionTracks, index);
|
playQueue(sectionTracks, index);
|
||||||
}
|
}
|
||||||
@@ -42,31 +40,40 @@
|
|||||||
type="button"
|
type="button"
|
||||||
aria-label={`Play ${track.title}`}
|
aria-label={`Play ${track.title}`}
|
||||||
onclick={onClick}
|
onclick={onClick}
|
||||||
class="flex w-full items-center gap-2 rounded-md p-1 pr-12 text-left
|
class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left
|
||||||
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
>
|
>
|
||||||
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
||||||
<img
|
<Cover src={cover} />
|
||||||
src={cover}
|
|
||||||
alt=""
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
loading="lazy"
|
|
||||||
onerror={onImgError}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
|
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
|
||||||
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
|
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<CardActionCluster
|
<!-- Single inline cluster, vertically centred on the right. The shared
|
||||||
likeEntityType="track"
|
CardActionCluster splits Like+Add (top) and the menu (bottom) into
|
||||||
likeEntityId={track.id}
|
opposite corners — correct for the tall square Album/Artist cards but
|
||||||
onAdd={onAddToQueue}
|
it makes the two groups collide on this short one-line row. A compact
|
||||||
addLabel={`Add ${track.title} to queue`}
|
row keeps all three controls in one horizontal group instead. -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute right-2 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5
|
||||||
|
opacity-0 transition-opacity duration-150
|
||||||
|
group-hover:opacity-100 focus-within:opacity-100"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{#snippet menu()}
|
<LikeButton entityType="track" entityId={track.id} size="sm" />
|
||||||
<TrackMenu {track} />
|
<button
|
||||||
{/snippet}
|
type="button"
|
||||||
</CardActionCluster>
|
aria-label={`Add ${track.title} to queue`}
|
||||||
|
onclick={onAddToQueue}
|
||||||
|
class="rounded-full bg-surface p-1 text-text-secondary hover:text-text-primary
|
||||||
|
focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<Plus size={16} strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
<TrackMenu {track} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
|||||||
|
|
||||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||||
|
|
||||||
vi.mock('$lib/api/admin/tracks', () => ({
|
|
||||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
||||||
|
|
||||||
vi.mock('$lib/auth/store.svelte', () => ({
|
vi.mock('$lib/auth/store.svelte', () => ({
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||||
|
import { Disc3 } from 'lucide-svelte';
|
||||||
|
|
||||||
|
// Shared cover-artwork renderer. The web counterpart of Android's
|
||||||
|
// CoverTile/ServerImage: one place that owns the loading placeholder and
|
||||||
|
// the error fallback so no card sits blank while art loads or 404s (the
|
||||||
|
// "You might like" row surfaces unplayed items whose art is often not yet
|
||||||
|
// backfilled). Replaces the three hand-rolled <img> tags that previously
|
||||||
|
// disagreed on fallback handling.
|
||||||
|
let {
|
||||||
|
src,
|
||||||
|
alt = '',
|
||||||
|
shape = 'square',
|
||||||
|
fallback = 'image',
|
||||||
|
class: klass = ''
|
||||||
|
}: {
|
||||||
|
src: string | undefined;
|
||||||
|
alt?: string;
|
||||||
|
// 'round' clips to a circle (artist avatars); 'square' leaves clipping
|
||||||
|
// to the parent so existing rounded-md / rounded corners still apply.
|
||||||
|
shape?: 'square' | 'round';
|
||||||
|
// 'artist' degrades to a Disc3 icon; 'image' to the static placeholder
|
||||||
|
// cover. A null/empty src is treated the same as a failed load.
|
||||||
|
fallback?: 'image' | 'artist';
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Per-image lifecycle. Reset when src changes so a reused card (virtualized
|
||||||
|
// list, client-side nav) never keeps a stale 'loaded'/'failed'.
|
||||||
|
let loaded = $state(false);
|
||||||
|
let failed = $state(false);
|
||||||
|
$effect(() => {
|
||||||
|
void src;
|
||||||
|
loaded = false;
|
||||||
|
failed = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const useIcon = $derived(fallback === 'artist' && (failed || !src));
|
||||||
|
// On failure for the image fallback, swap to the static placeholder asset.
|
||||||
|
// src doesn't change on the second error, so there's no reload loop.
|
||||||
|
const imgSrc = $derived(failed ? FALLBACK_COVER : src);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative h-full w-full overflow-hidden bg-surface-hover {shape === 'round'
|
||||||
|
? 'rounded-full'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
{#if useIcon}
|
||||||
|
<div class="flex h-full w-full items-center justify-center">
|
||||||
|
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
|
||||||
|
</div>
|
||||||
|
{:else if imgSrc}
|
||||||
|
<img
|
||||||
|
src={imgSrc}
|
||||||
|
{alt}
|
||||||
|
class="h-full w-full object-cover transition-opacity duration-200 {loaded
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0'} {klass}"
|
||||||
|
loading="lazy"
|
||||||
|
onload={() => (loaded = true)}
|
||||||
|
onerror={() => (failed = true)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -272,8 +272,12 @@
|
|||||||
data-testid="player-bar-desktop"
|
data-testid="player-bar-desktop"
|
||||||
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
|
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
|
||||||
>
|
>
|
||||||
<!-- Left: cover + title + artist + like + menu -->
|
<!-- Left: cover + title + artist + like + menu.
|
||||||
<div class="flex w-72 min-w-0 items-center gap-3">
|
basis-72 grow max-w-md: holds its 288px floor at the md breakpoint
|
||||||
|
(where the seek column is the flex-1 that absorbs the remainder), but
|
||||||
|
on wider screens it grows up to 448px instead of leaving the title
|
||||||
|
truncated while the seek bar hogs the slack. -->
|
||||||
|
<div class="flex basis-72 grow max-w-md min-w-0 items-center gap-3">
|
||||||
<a
|
<a
|
||||||
href="/now-playing"
|
href="/now-playing"
|
||||||
aria-label="Open now playing"
|
aria-label="Open now playing"
|
||||||
@@ -318,7 +322,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex flex-1 flex-col items-stretch gap-2">
|
<!-- max-w-xl + mx-auto caps the seek bar (and the transport row) at
|
||||||
|
576px and centres it, so on wide screens the extra width flows to
|
||||||
|
the title column on the left rather than stretching the scrubber. -->
|
||||||
|
<div class="flex flex-1 flex-col items-stretch gap-2 mx-auto w-full max-w-xl">
|
||||||
<!-- Transport row -->
|
<!-- Transport row -->
|
||||||
<div class="flex items-center justify-center gap-3">
|
<div class="flex items-center justify-center gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import { user } from '$lib/auth/store.svelte';
|
import { user } from '$lib/auth/store.svelte';
|
||||||
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
||||||
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
||||||
|
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
@@ -80,7 +81,14 @@
|
|||||||
const detail = await systemShuffle(variant);
|
const detail = await systemShuffle(variant);
|
||||||
const refs = toTrackRefs(detail.tracks);
|
const refs = toTrackRefs(detail.tracks);
|
||||||
if (refs.length > 0) {
|
if (refs.length > 0) {
|
||||||
playQueue(refs, 0, { source: variant });
|
playQueue(refs, 0, {
|
||||||
|
source: variant,
|
||||||
|
refetch: systemPlaylistRefetch({
|
||||||
|
variant,
|
||||||
|
playlistId: playlist.id,
|
||||||
|
perArtist: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const detail = await getPlaylist(playlist.id);
|
const detail = await getPlaylist(playlist.id);
|
||||||
@@ -93,7 +101,14 @@
|
|||||||
// so source attribution stays absent — the PlaylistCard
|
// so source attribution stays absent — the PlaylistCard
|
||||||
// test pins this contract.
|
// test pins this contract.
|
||||||
if (variant != null) {
|
if (variant != null) {
|
||||||
playQueue(refs, 0, { source: variant });
|
playQueue(refs, 0, {
|
||||||
|
source: variant,
|
||||||
|
refetch: systemPlaylistRefetch({
|
||||||
|
variant,
|
||||||
|
playlistId: playlist.id,
|
||||||
|
perArtist: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
playQueue(refs, 0);
|
playQueue(refs, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
|||||||
|
|
||||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||||
|
|
||||||
vi.mock('$lib/api/admin/tracks', () => ({
|
|
||||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' })
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('$lib/player/store.svelte', () => ({
|
||||||
playNext: vi.fn(),
|
playNext: vi.fn(),
|
||||||
enqueueTrack: vi.fn(),
|
enqueueTrack: vi.fn(),
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { Trash2 } from 'lucide-svelte';
|
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
|
||||||
import { removeTrack } from '$lib/api/admin/tracks';
|
|
||||||
import { errMessage } from '$lib/api/errors';
|
|
||||||
import { qk } from '$lib/api/queries';
|
|
||||||
import type { TrackRef } from '$lib/api/types';
|
|
||||||
|
|
||||||
let {
|
|
||||||
track,
|
|
||||||
onClose
|
|
||||||
}: {
|
|
||||||
track: TrackRef;
|
|
||||||
onClose: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
let unmonitor = $state(false);
|
|
||||||
let busy = $state(false);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
|
|
||||||
async function confirm() {
|
|
||||||
busy = true;
|
|
||||||
error = null;
|
|
||||||
try {
|
|
||||||
const result = await removeTrack(track.id, { unmonitor });
|
|
||||||
// Invalidate caches for any vanished entity. The album/artist that
|
|
||||||
// contained the track is always touched; if the parent rows were
|
|
||||||
// tidied up by the server cascade, evict those too. Home payload
|
|
||||||
// surfaces albums/artists on the landing page so it always
|
|
||||||
// refreshes.
|
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.album(track.album_id) });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.artist(track.artist_id) });
|
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.home() });
|
|
||||||
if (result.deleted_album_id) {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.album(result.deleted_album_id) });
|
|
||||||
}
|
|
||||||
if (result.deleted_artist_id) {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: qk.artist(result.deleted_artist_id) });
|
|
||||||
}
|
|
||||||
// Note: result.lidarr_unmonitor_failed is true when unmonitor was
|
|
||||||
// requested AND Lidarr couldn't be reached. The destructive part
|
|
||||||
// already succeeded; we still close the popover. A future toast
|
|
||||||
// surface (if any) can pick up the flag.
|
|
||||||
onClose();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error = errMessage(e);
|
|
||||||
} finally {
|
|
||||||
busy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-modal="false"
|
|
||||||
aria-labelledby="remove-track-popover-title"
|
|
||||||
class="absolute right-0 z-30 mt-1 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl"
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose(); }}
|
|
||||||
>
|
|
||||||
<h4 id="remove-track-popover-title" class="text-sm font-medium text-text-primary">
|
|
||||||
Remove "{track.title}" from your library?
|
|
||||||
</h4>
|
|
||||||
<p class="mt-1 text-xs text-text-muted">
|
|
||||||
This deletes the file from disk.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<label class="mt-3 flex items-start gap-2 text-sm text-text-primary">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
bind:checked={unmonitor}
|
|
||||||
class="mt-0.5 rounded border-border"
|
|
||||||
/>
|
|
||||||
<span>Also stop Lidarr from finding a replacement</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p class="mt-2 text-xs text-action-destructive">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-border px-2.5 py-1 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
|
||||||
onclick={onClose}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={confirm}
|
|
||||||
disabled={busy}
|
|
||||||
class="inline-flex items-center gap-1 rounded-md border border-border bg-surface px-2.5 py-1 text-sm text-action-destructive hover:bg-surface-hover disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Trash2 size={14} strokeWidth={1} />
|
|
||||||
{busy ? 'Removing…' : 'Remove'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
||||||
import { makeTrack } from '$test-utils/fixtures/track';
|
|
||||||
|
|
||||||
const invalidateMock = vi.fn();
|
|
||||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
|
||||||
const actual = (await orig()) as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useQueryClient: () => ({ invalidateQueries: invalidateMock })
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
vi.mock('$lib/api/admin/tracks', () => ({
|
|
||||||
removeTrack: vi.fn()
|
|
||||||
}));
|
|
||||||
|
|
||||||
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
|
|
||||||
import { removeTrack } from '$lib/api/admin/tracks';
|
|
||||||
|
|
||||||
const track = makeTrack({ title: 'Roygbiv' });
|
|
||||||
|
|
||||||
afterEach(() => vi.clearAllMocks());
|
|
||||||
|
|
||||||
describe('RemoveTrackPopover', () => {
|
|
||||||
test('renders the title and subtext', () => {
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
|
||||||
expect(screen.getByText(/remove "roygbiv" from your library/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/this deletes the file from disk/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders the Lidarr unmonitor checkbox', () => {
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
|
||||||
const cb = screen.getByRole('checkbox', { name: /also stop lidarr/i });
|
|
||||||
expect(cb).toBeInTheDocument();
|
|
||||||
expect((cb as HTMLInputElement).checked).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Cancel button calls onClose without firing removeTrack', async () => {
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
|
||||||
expect(removeTrack).not.toHaveBeenCalled();
|
|
||||||
expect(onClose).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Remove without checkbox calls removeTrack with unmonitor=false', async () => {
|
|
||||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
|
||||||
// confirm() chains 3-5 awaited invalidateQueries calls before onClose,
|
|
||||||
// each a separate microtask. waitFor polls until the side effects land.
|
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
||||||
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('Remove with checkbox checked calls removeTrack with unmonitor=true', async () => {
|
|
||||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
|
||||||
await fireEvent.click(screen.getByRole('checkbox', { name: /also stop lidarr/i }));
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
|
||||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
||||||
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('failed remove surfaces error via copyForCode and does not close', async () => {
|
|
||||||
(removeTrack as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'not_found' });
|
|
||||||
const onClose = vi.fn();
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
|
||||||
await waitFor(() => expect(removeTrack).toHaveBeenCalled());
|
|
||||||
// The catch path runs synchronously after the rejected await; one more
|
|
||||||
// microtask boundary is enough.
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(onClose).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('success invalidates album, artist and home queries', async () => {
|
|
||||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
deleted_track_id: 't1',
|
|
||||||
deleted_album_id: 'a1',
|
|
||||||
deleted_artist_id: 'ar1'
|
|
||||||
});
|
|
||||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
|
||||||
// Wait for all three baseline invalidations (album, artist, home) plus
|
|
||||||
// the two cascade ones (deleted_album_id, deleted_artist_id) to land.
|
|
||||||
await waitFor(() => expect(invalidateMock.mock.calls.length).toBeGreaterThanOrEqual(5));
|
|
||||||
const keys = invalidateMock.mock.calls.map((c) => c[0].queryKey);
|
|
||||||
const flat = keys.map((k) => Array.isArray(k) ? k.join('|') : String(k));
|
|
||||||
expect(flat.some((k) => k.startsWith('album|a1'))).toBe(true);
|
|
||||||
expect(flat.some((k) => k.startsWith('artist|ar1'))).toBe(true);
|
|
||||||
expect(flat.some((k) => k === 'home')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,30 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
ListPlus,
|
ListVideo,
|
||||||
Plus,
|
ListMusic,
|
||||||
Heart,
|
Heart,
|
||||||
HeartOff,
|
HeartOff,
|
||||||
ListMusic,
|
ListPlus,
|
||||||
Album,
|
Radio,
|
||||||
Disc3,
|
Disc3,
|
||||||
Flag,
|
User,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Eye,
|
Eye
|
||||||
Trash2
|
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import FlagPopover from './FlagPopover.svelte';
|
import FlagPopover from './FlagPopover.svelte';
|
||||||
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
|
|
||||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||||
import { user } from '$lib/auth/store.svelte';
|
|
||||||
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
||||||
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
|
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
|
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -44,7 +41,6 @@
|
|||||||
|
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let flagOpen = $state(false);
|
let flagOpen = $state(false);
|
||||||
let removeOpen = $state(false);
|
|
||||||
let addOpen = $state(false);
|
let addOpen = $state(false);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -63,8 +59,6 @@
|
|||||||
return rows.some((r) => r.track_id === track.id);
|
return rows.some((r) => r.track_id === track.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isAdmin = $derived(user.value?.is_admin === true);
|
|
||||||
|
|
||||||
function toggleMenu(e: MouseEvent) {
|
function toggleMenu(e: MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
menuOpen = !menuOpen;
|
menuOpen = !menuOpen;
|
||||||
@@ -73,7 +67,6 @@
|
|||||||
function closeAll() {
|
function closeAll() {
|
||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
flagOpen = false;
|
flagOpen = false;
|
||||||
removeOpen = false;
|
|
||||||
addOpen = false;
|
addOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +80,11 @@
|
|||||||
closeAll();
|
closeAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onStartRadio() {
|
||||||
|
playRadio(track.id);
|
||||||
|
closeAll();
|
||||||
|
}
|
||||||
|
|
||||||
async function onToggleLike() {
|
async function onToggleLike() {
|
||||||
closeAll();
|
closeAll();
|
||||||
if (liked) {
|
if (liked) {
|
||||||
@@ -119,18 +117,8 @@
|
|||||||
goto(`/artists/${track.artist_id}`);
|
goto(`/artists/${track.artist_id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFlag() {
|
|
||||||
menuOpen = false;
|
|
||||||
flagOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onRemoveOpen() {
|
|
||||||
menuOpen = false;
|
|
||||||
removeOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeydown(e: KeyboardEvent) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return;
|
if (!menuOpen && !flagOpen && !addOpen) return;
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
closeAll();
|
closeAll();
|
||||||
@@ -161,9 +149,16 @@
|
|||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') menuOpen = false; }}
|
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') menuOpen = false; }}
|
||||||
>
|
>
|
||||||
|
<!-- Item set + order mirror Android's canonical TrackActionsSheet so
|
||||||
|
the kebab reads the same on both clients: queue group → like /
|
||||||
|
add-to-playlist / start-radio group → navigation group → hide.
|
||||||
|
"Start radio" is shown even under hideQueueActions (reseeding a
|
||||||
|
station from the current track is meaningful where play-next /
|
||||||
|
add-to-queue are not). No single-click destructive action lives
|
||||||
|
here — track removal is intentionally kept out of the kebab. -->
|
||||||
{#if !hideQueueActions}
|
{#if !hideQueueActions}
|
||||||
<TrackMenuItem icon={ListPlus} label="Play next" onclick={onPlayNext} />
|
<TrackMenuItem icon={ListVideo} label="Play next" onclick={onPlayNext} />
|
||||||
<TrackMenuItem icon={Plus} label="Add to queue" onclick={onAddToQueue} />
|
<TrackMenuItem icon={ListMusic} label="Add to queue" onclick={onAddToQueue} />
|
||||||
|
|
||||||
<TrackMenuDivider />
|
<TrackMenuDivider />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -174,32 +169,24 @@
|
|||||||
onclick={onToggleLike}
|
onclick={onToggleLike}
|
||||||
/>
|
/>
|
||||||
<TrackMenuItem
|
<TrackMenuItem
|
||||||
icon={ListMusic}
|
icon={ListPlus}
|
||||||
label="Add to playlist…"
|
label="Add to playlist…"
|
||||||
onclick={() => { addOpen = true; menuOpen = false; }}
|
onclick={() => { addOpen = true; menuOpen = false; }}
|
||||||
/>
|
/>
|
||||||
|
<TrackMenuItem icon={Radio} label="Start radio" onclick={onStartRadio} />
|
||||||
|
|
||||||
<TrackMenuDivider />
|
<TrackMenuDivider />
|
||||||
|
|
||||||
<TrackMenuItem icon={Album} label="Go to album" onclick={onGoToAlbum} />
|
<TrackMenuItem icon={Disc3} label="Go to album" onclick={onGoToAlbum} />
|
||||||
<TrackMenuItem icon={Disc3} label="Go to artist" onclick={onGoToArtist} />
|
<TrackMenuItem icon={User} label="Go to artist" onclick={onGoToArtist} />
|
||||||
|
|
||||||
<TrackMenuDivider />
|
<TrackMenuDivider />
|
||||||
|
|
||||||
<TrackMenuItem icon={Flag} label="Flag this track…" onclick={onFlag} />
|
|
||||||
<TrackMenuItem
|
<TrackMenuItem
|
||||||
icon={hidden ? Eye : EyeOff}
|
icon={hidden ? Eye : EyeOff}
|
||||||
label={hidden ? 'Unhide' : 'Hide'}
|
label={hidden ? 'Unhide' : 'Hide'}
|
||||||
onclick={onToggleHide}
|
onclick={onToggleHide}
|
||||||
/>
|
/>
|
||||||
{#if isAdmin}
|
|
||||||
<TrackMenuItem
|
|
||||||
icon={Trash2}
|
|
||||||
label="Remove from library"
|
|
||||||
onclick={onRemoveOpen}
|
|
||||||
danger
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -207,10 +194,6 @@
|
|||||||
<FlagPopover {track} onClose={closeAll} />
|
<FlagPopover {track} onClose={closeAll} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if removeOpen}
|
|
||||||
<RemoveTrackPopover {track} onClose={closeAll} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if addOpen}
|
{#if addOpen}
|
||||||
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
|
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -25,19 +25,16 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
|||||||
|
|
||||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||||
|
|
||||||
vi.mock('$lib/api/admin/tracks', () => ({
|
|
||||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
||||||
|
|
||||||
vi.mock('$lib/player/store.svelte', () => ({
|
vi.mock('$lib/player/store.svelte', () => ({
|
||||||
playNext: vi.fn(),
|
playNext: vi.fn(),
|
||||||
enqueueTrack: vi.fn()
|
enqueueTrack: vi.fn(),
|
||||||
|
playRadio: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import TrackMenu from './TrackMenu.svelte';
|
import TrackMenu from './TrackMenu.svelte';
|
||||||
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
|
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
||||||
|
|
||||||
const track = makeTrack({ title: 'Roygbiv' });
|
const track = makeTrack({ title: 'Roygbiv' });
|
||||||
|
|
||||||
@@ -55,7 +52,7 @@ describe('TrackMenu', () => {
|
|||||||
expect(kebab.getAttribute('aria-expanded')).toBe('true');
|
expect(kebab.getAttribute('aria-expanded')).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('opening menu shows all 9 entries for an admin user', async () => {
|
test('opening menu shows all 8 entries', async () => {
|
||||||
render(TrackMenu, { props: { track } });
|
render(TrackMenu, { props: { track } });
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||||
|
|
||||||
@@ -63,19 +60,12 @@ describe('TrackMenu', () => {
|
|||||||
expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('menuitem', { name: /start radio/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument();
|
|
||||||
expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument();
|
expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getAllByRole('menuitem')).toHaveLength(9);
|
// No "Remove from library" — destructive removal is intentionally absent.
|
||||||
});
|
|
||||||
|
|
||||||
test('"Remove from library" hidden for non-admin', async () => {
|
|
||||||
userState.current = { id: 'u2', username: 'normal', is_admin: false };
|
|
||||||
render(TrackMenu, { props: { track } });
|
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
|
||||||
expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument();
|
||||||
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
|
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
|
||||||
});
|
});
|
||||||
@@ -113,10 +103,17 @@ describe('TrackMenu', () => {
|
|||||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking "Flag this track…" opens FlagPopover and closes menu', async () => {
|
test('"Start radio" dispatches playRadio(track.id)', async () => {
|
||||||
render(TrackMenu, { props: { track } });
|
render(TrackMenu, { props: { track } });
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||||
await fireEvent.click(screen.getByRole('menuitem', { name: /flag this track/i }));
|
await fireEvent.click(screen.getByRole('menuitem', { name: /start radio/i }));
|
||||||
|
expect(playRadio).toHaveBeenCalledWith(track.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking "Hide" opens the flag/quarantine popover and closes menu', async () => {
|
||||||
|
render(TrackMenu, { props: { track } });
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||||
|
await fireEvent.click(screen.getByRole('menuitem', { name: /^hide$/i }));
|
||||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('dialog', { name: /flag this track as broken/i })
|
screen.getByRole('dialog', { name: /flag this track as broken/i })
|
||||||
|
|||||||
@@ -83,6 +83,23 @@ let _radioRefreshInFlight = false;
|
|||||||
// queue clears it).
|
// queue clears it).
|
||||||
let _queueSource = $state<string | null>(null);
|
let _queueSource = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Track B (#968): consecutive load failures since the last successful
|
||||||
|
// 'playing'. A bad track auto-advances instead of dead-ending; the streak
|
||||||
|
// (capped against queue length) stops a fully-unplayable queue from
|
||||||
|
// looping forever. Reset on a successful play and on a fresh playQueue.
|
||||||
|
let _failureStreak = 0;
|
||||||
|
|
||||||
|
// Track B (#968): when a queue is seeded from a refreshable source (a system
|
||||||
|
// playlist or radio), the seeder hands us a closure that re-pulls the fresh
|
||||||
|
// snapshot. On total failure (whole queue unplayable — likely a stale tab
|
||||||
|
// left open across the daily rebuild) we call it once to self-heal before
|
||||||
|
// surfacing the dead-end. Kept opaque so the store stays decoupled from the
|
||||||
|
// playlist API and the per-artist (songs_like_artist) identity problem.
|
||||||
|
let _queueRefetch: (() => Promise<TrackRef[]>) | null = null;
|
||||||
|
let _selfHealInFlight = false;
|
||||||
|
let _selfHealAttempts = 0;
|
||||||
|
const MAX_SELF_HEAL_ATTEMPTS = 1;
|
||||||
|
|
||||||
let _audioEl: HTMLAudioElement | null = null;
|
let _audioEl: HTMLAudioElement | null = null;
|
||||||
|
|
||||||
export const player = {
|
export const player = {
|
||||||
@@ -109,13 +126,20 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
|
|||||||
export function playQueue(
|
export function playQueue(
|
||||||
tracks: TrackRef[],
|
tracks: TrackRef[],
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
opts: { shuffle?: boolean; source?: string | null } = {},
|
opts: {
|
||||||
|
shuffle?: boolean;
|
||||||
|
source?: string | null;
|
||||||
|
refetch?: () => Promise<TrackRef[]>;
|
||||||
|
} = {},
|
||||||
): void {
|
): void {
|
||||||
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
||||||
// #415: a fresh queue resets the system-playlist source. Set only
|
// #415: a fresh queue resets the system-playlist source. Set only
|
||||||
// when seeded from a system playlist (server already returned the
|
// when seeded from a system playlist (server already returned the
|
||||||
// rotation-aware order, so no client shuffle in that path).
|
// rotation-aware order, so no client shuffle in that path).
|
||||||
_queueSource = opts.source ?? null;
|
_queueSource = opts.source ?? null;
|
||||||
|
// #968: a fresh play replaces the self-heal closure + resets its budget.
|
||||||
|
_queueRefetch = opts.refetch ?? null;
|
||||||
|
_selfHealAttempts = 0;
|
||||||
if (opts.shuffle && tracks.length > 1) {
|
if (opts.shuffle && tracks.length > 1) {
|
||||||
// Fisher-Yates over the whole list. startIndex is ignored — the
|
// Fisher-Yates over the whole list. startIndex is ignored — the
|
||||||
// caller is asking for "random play from this pool," so the first
|
// caller is asking for "random play from this pool," so the first
|
||||||
@@ -142,6 +166,7 @@ export function playQueue(
|
|||||||
_position = 0;
|
_position = 0;
|
||||||
_duration = 0;
|
_duration = 0;
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_failureStreak = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function togglePlay(): void {
|
export function togglePlay(): void {
|
||||||
@@ -263,6 +288,8 @@ export function reportStateFromAudio(
|
|||||||
case 'playing':
|
case 'playing':
|
||||||
_state = 'playing';
|
_state = 'playing';
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_failureStreak = 0;
|
||||||
|
_selfHealAttempts = 0;
|
||||||
return;
|
return;
|
||||||
case 'paused':
|
case 'paused':
|
||||||
_state = 'paused';
|
_state = 'paused';
|
||||||
@@ -271,8 +298,7 @@ export function reportStateFromAudio(
|
|||||||
_state = 'loading';
|
_state = 'loading';
|
||||||
return;
|
return;
|
||||||
case 'error':
|
case 'error':
|
||||||
_state = 'error';
|
handleLoadFailure(detail);
|
||||||
_error = detail ?? 'Playback failed.';
|
|
||||||
return;
|
return;
|
||||||
case 'ended':
|
case 'ended':
|
||||||
if (_repeat === 'one') {
|
if (_repeat === 'one') {
|
||||||
@@ -301,6 +327,68 @@ export function reportStateFromAudio(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track B (#968): a single failed track must not strand the player on the
|
||||||
|
// "Try again" dead-end. Advance past it; only surface the error once the
|
||||||
|
// whole queue has proven unplayable — every track failed, or we reached the
|
||||||
|
// end with nothing playable. The streak cap (vs queue length) stops a
|
||||||
|
// fully-broken queue from cycling forever instead of settling.
|
||||||
|
function handleLoadFailure(detail?: string): void {
|
||||||
|
_failureStreak++;
|
||||||
|
if (_failureStreak < _queue.length && _index + 1 < _queue.length) {
|
||||||
|
_index++;
|
||||||
|
_position = 0;
|
||||||
|
_duration = 0;
|
||||||
|
_state = 'loading';
|
||||||
|
_error = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Whole queue is unplayable. If it came from a refreshable source, the
|
||||||
|
// snapshot is probably stale — re-pull it and resume before giving up.
|
||||||
|
if (trySelfHeal()) return;
|
||||||
|
_state = 'error';
|
||||||
|
_error = detail ?? 'Playback failed.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// #968: re-pull a stale refreshable queue. Returns true if a self-heal was
|
||||||
|
// started (caller must not dead-end). Bounded to one attempt per exhaustion
|
||||||
|
// (reset on the next successful play) so a still-broken refresh can't loop.
|
||||||
|
function trySelfHeal(): boolean {
|
||||||
|
if (_selfHealInFlight) return true;
|
||||||
|
if (_queueRefetch === null) return false;
|
||||||
|
if (_selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) return false;
|
||||||
|
_selfHealAttempts++;
|
||||||
|
_selfHealInFlight = true;
|
||||||
|
_state = 'loading';
|
||||||
|
_error = null;
|
||||||
|
const refetch = _queueRefetch;
|
||||||
|
void refetch()
|
||||||
|
.then((refs) => {
|
||||||
|
if (refs.length > 0) {
|
||||||
|
// Re-seed in place — keep _queueSource / _queueRefetch so the new
|
||||||
|
// snapshot can itself self-heal, and don't reset _selfHealAttempts
|
||||||
|
// (only a successful 'playing' clears the budget).
|
||||||
|
_queue = refs;
|
||||||
|
_index = 0;
|
||||||
|
_position = 0;
|
||||||
|
_duration = 0;
|
||||||
|
_failureStreak = 0;
|
||||||
|
_state = 'loading';
|
||||||
|
_error = null;
|
||||||
|
} else {
|
||||||
|
_state = 'error';
|
||||||
|
_error = 'Nothing playable in this mix right now.';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
_state = 'error';
|
||||||
|
_error = 'Couldn’t refresh this mix. Try again.';
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
_selfHealInFlight = false;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function enqueueTrack(t: TrackRef): void {
|
export function enqueueTrack(t: TrackRef): void {
|
||||||
_radioSeedId = null; // M4c
|
_radioSeedId = null; // M4c
|
||||||
_queue = [..._queue, t];
|
_queue = [..._queue, t];
|
||||||
@@ -345,12 +433,19 @@ export function playNextMany(ts: TrackRef[]): void {
|
|||||||
_queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
|
_queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function playRadio(seedTrackId: string): Promise<void> {
|
async function fetchRadioTracks(seedTrackId: string): Promise<TrackRef[]> {
|
||||||
const resp = await api.get<RadioResponse>(
|
const resp = await api.get<RadioResponse>(
|
||||||
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
||||||
);
|
);
|
||||||
if (resp.tracks.length === 0) return;
|
return resp.tracks;
|
||||||
playQueue(resp.tracks, 0);
|
}
|
||||||
|
|
||||||
|
export async function playRadio(seedTrackId: string): Promise<void> {
|
||||||
|
const tracks = await fetchRadioTracks(seedTrackId);
|
||||||
|
if (tracks.length === 0) return;
|
||||||
|
// #968: hand the player a self-heal closure so a fully-stale radio queue
|
||||||
|
// re-seeds from the same track instead of dead-ending.
|
||||||
|
playQueue(tracks, 0, { refetch: () => fetchRadioTracks(seedTrackId) });
|
||||||
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,50 @@ describe('player store — shuffle + repeat + audio reports', () => {
|
|||||||
expect(player.state).toBe('error');
|
expect(player.state).toBe('error');
|
||||||
expect(player.error).toBe('network lost');
|
expect(player.error).toBe('network lost');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reportStateFromAudio("error") auto-advances past a bad track', () => {
|
||||||
|
playQueue([track('1'), track('2'), track('3')]);
|
||||||
|
reportStateFromAudio('error', 'boom');
|
||||||
|
expect(player.index).toBe(1);
|
||||||
|
expect(player.state).toBe('loading');
|
||||||
|
expect(player.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reportStateFromAudio("error") dead-ends once the whole queue is unplayable', () => {
|
||||||
|
playQueue([track('1'), track('2')]);
|
||||||
|
reportStateFromAudio('error', 'boom'); // track 1 fails → skip to track 2
|
||||||
|
expect(player.index).toBe(1);
|
||||||
|
expect(player.state).toBe('loading');
|
||||||
|
reportStateFromAudio('error', 'boom'); // track 2 also fails → exhausted
|
||||||
|
expect(player.state).toBe('error');
|
||||||
|
expect(player.error).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reportStateFromAudio("error") self-heals a refreshable queue on total failure', async () => {
|
||||||
|
const fresh = [track('a'), track('b')];
|
||||||
|
const refetch = vi.fn().mockResolvedValue(fresh);
|
||||||
|
playQueue([track('1')], 0, { source: 'for_you', refetch });
|
||||||
|
reportStateFromAudio('error', 'boom'); // sole track fails → exhausted → self-heal
|
||||||
|
expect(refetch).toHaveBeenCalledOnce();
|
||||||
|
expect(player.state).toBe('loading');
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(player.queue.map((t) => t.id)).toEqual(['a', 'b']);
|
||||||
|
expect(player.index).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('self-heal fires at most once per exhaustion, then dead-ends', async () => {
|
||||||
|
const refetch = vi.fn().mockResolvedValue([track('x')]);
|
||||||
|
playQueue([track('1')], 0, { source: 'for_you', refetch });
|
||||||
|
reportStateFromAudio('error', 'boom'); // exhausted → self-heal #1
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
reportStateFromAudio('error', 'boom'); // re-pulled track also fails; budget spent → error
|
||||||
|
expect(player.state).toBe('error');
|
||||||
|
expect(refetch).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { TrackRef, PlaylistDetail } from '$lib/api/types';
|
||||||
|
import { systemShuffle, getPlaylist } from '$lib/api/playlists';
|
||||||
|
import { playlistTrackToRef } from './playlistTrackToRef';
|
||||||
|
|
||||||
|
// #968: builds the self-heal closure for a system-playlist queue. On total
|
||||||
|
// playback failure (every queued track unplayable — typically a stale tab
|
||||||
|
// left open across the daily rebuild) the player calls this to re-pull the
|
||||||
|
// current snapshot and resume.
|
||||||
|
//
|
||||||
|
// Single-instance variants (for_you, discover, deep_cuts, …) re-pull by
|
||||||
|
// variant via the rotation-aware shuffle endpoint. Per-artist mixes
|
||||||
|
// (songs_like_artist — one playlist per seed artist) can't be addressed by
|
||||||
|
// variant alone, so they re-pull by playlist id. Mirrors the play routing
|
||||||
|
// in PlaylistCard.
|
||||||
|
|
||||||
|
function toRefs(detail: PlaylistDetail): TrackRef[] {
|
||||||
|
return detail.tracks
|
||||||
|
.map((r) => playlistTrackToRef(r))
|
||||||
|
.filter((t): t is TrackRef => t !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function systemPlaylistRefetch(opts: {
|
||||||
|
variant: string;
|
||||||
|
playlistId: string;
|
||||||
|
perArtist: boolean;
|
||||||
|
}): () => Promise<TrackRef[]> {
|
||||||
|
return async () => {
|
||||||
|
const detail = opts.perArtist
|
||||||
|
? await getPlaylist(opts.playlistId)
|
||||||
|
: await systemShuffle(opts.variant);
|
||||||
|
return toRefs(detail);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { queryClient } from '$lib/query/client';
|
||||||
|
import { qk } from '$lib/api/queries';
|
||||||
|
import { user } from '$lib/auth/store.svelte';
|
||||||
|
|
||||||
|
// Inbound server events over SSE (#968). The web client's OUTBOUND half
|
||||||
|
// (play events) lives in player/events.svelte.ts; this is the inbound
|
||||||
|
// listener it never had. Today it reacts to `playlist.system_rebuilt` — the
|
||||||
|
// daily 03:00 rebuild or a manual refresh — by invalidating the home +
|
||||||
|
// system-playlist query caches so a tab left open across the rebuild stops
|
||||||
|
// serving yesterday's snapshot (the stale-browse-view bug). Active playback
|
||||||
|
// self-heals separately on the failure path (store.handleLoadFailure); we
|
||||||
|
// deliberately do NOT yank a playing queue here — that would interrupt a
|
||||||
|
// mid-song listen to restart the new mix at track 0.
|
||||||
|
|
||||||
|
// Monotonic rebuild counter (#980). Bumped on every playlist.system_rebuilt.
|
||||||
|
// An open system-playlist DETAIL page can't be invalidated in place (its id
|
||||||
|
// rotates on rebuild — a refetch would 404), so instead it watches this
|
||||||
|
// counter and offers a "this mix was refreshed → Refresh" affordance that
|
||||||
|
// re-resolves the variant to the new playlist.
|
||||||
|
let _systemRebuildCount = $state(0);
|
||||||
|
export const systemRebuilt = {
|
||||||
|
get count(): number {
|
||||||
|
return _systemRebuildCount;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function onSystemRebuilt(): void {
|
||||||
|
_systemRebuildCount++;
|
||||||
|
queryClient.invalidateQueries({ queryKey: qk.home() });
|
||||||
|
// Prefix match invalidates every kind ('user' | 'system' | 'all').
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['playlists'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: qk.systemPlaylistsStatus() });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire once from +layout.svelte's mount. Opens the stream only while
|
||||||
|
// authenticated and closes it on logout; EventSource auto-reconnects on
|
||||||
|
// transient network drops.
|
||||||
|
export function useServerEvents(): void {
|
||||||
|
$effect(() => {
|
||||||
|
if (!user.value) return;
|
||||||
|
// Absent under SSR / jsdom — the listener simply no-ops there.
|
||||||
|
if (typeof EventSource === 'undefined') return;
|
||||||
|
const es = new EventSource('/api/events/stream');
|
||||||
|
es.addEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
||||||
|
return () => {
|
||||||
|
es.removeEventListener('playlist.system_rebuilt', onSystemRebuilt);
|
||||||
|
es.close();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
} from '$lib/player/store.svelte';
|
} from '$lib/player/store.svelte';
|
||||||
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
||||||
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
||||||
|
import { useServerEvents } from '$lib/serverEvents.svelte';
|
||||||
import { useGlobalShortcuts } from '$lib/player/shortcuts.svelte';
|
import { useGlobalShortcuts } from '$lib/player/shortcuts.svelte';
|
||||||
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
||||||
import { audioLoader } from '$lib/player/audioLoader';
|
import { audioLoader } from '$lib/player/audioLoader';
|
||||||
@@ -132,6 +133,7 @@
|
|||||||
|
|
||||||
useMediaSession();
|
useMediaSession();
|
||||||
useEventsDispatcher();
|
useEventsDispatcher();
|
||||||
|
useServerEvents();
|
||||||
useGlobalShortcuts();
|
useGlobalShortcuts();
|
||||||
applyMetaThemeColor();
|
applyMetaThemeColor();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -161,6 +161,42 @@
|
|||||||
{:else if showSkeleton.value && !data}
|
{:else if showSkeleton.value && !data}
|
||||||
<p class="text-text-secondary">Loading…</p>
|
<p class="text-text-secondary">Loading…</p>
|
||||||
{:else if data}
|
{:else if data}
|
||||||
|
<!-- You might like: taste-predicted in-library albums + artists the user
|
||||||
|
doesn't actively spin (server section, daily-built, cold-start gated).
|
||||||
|
Sits directly under the system playlists row. Two scrollers like
|
||||||
|
Rediscover (square album vs circular artist cards). -->
|
||||||
|
<section class="space-y-3">
|
||||||
|
{#if data.you_might_like_albums.length === 0 && data.you_might_like_artists.length === 0}
|
||||||
|
<header>
|
||||||
|
<h2 class="font-display text-2xl font-medium text-text-primary">You might like</h2>
|
||||||
|
</header>
|
||||||
|
<p class="text-text-secondary">We're still learning your taste — keep listening and picks you might like will show up here.</p>
|
||||||
|
{:else}
|
||||||
|
{#if data.you_might_like_albums.length > 0}
|
||||||
|
<HorizontalScrollRow
|
||||||
|
rows={[data.you_might_like_albums]}
|
||||||
|
title="You might like"
|
||||||
|
ariaLabel="You might like albums"
|
||||||
|
>
|
||||||
|
{#snippet item(album: import('$lib/api/types').AlbumRef)}
|
||||||
|
<div class="w-40"><AlbumCard {album} /></div>
|
||||||
|
{/snippet}
|
||||||
|
</HorizontalScrollRow>
|
||||||
|
{/if}
|
||||||
|
{#if data.you_might_like_artists.length > 0}
|
||||||
|
<HorizontalScrollRow
|
||||||
|
rows={[data.you_might_like_artists]}
|
||||||
|
title={data.you_might_like_albums.length === 0 ? 'You might like' : undefined}
|
||||||
|
ariaLabel="You might like artists"
|
||||||
|
>
|
||||||
|
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
|
||||||
|
<div class="w-36"><ArtistCard {artist} /></div>
|
||||||
|
{/snippet}
|
||||||
|
</HorizontalScrollRow>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Recently added: 50 albums in 2 rows of 25, scrolling together -->
|
<!-- Recently added: 50 albums in 2 rows of 25, scrolling together -->
|
||||||
<section class="space-y-3">
|
<section class="space-y-3">
|
||||||
{#if data.recently_added_albums.length === 0}
|
{#if data.recently_added_albums.length === 0}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||||
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
||||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||||
|
import TrackMenu from '$lib/components/TrackMenu.svelte';
|
||||||
import QueueList from '$lib/components/QueueList.svelte';
|
import QueueList from '$lib/components/QueueList.svelte';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
|
|
||||||
@@ -78,6 +79,15 @@
|
|||||||
<ArrowLeft size={22} strokeWidth={1.5} />
|
<ArrowLeft size={22} strokeWidth={1.5} />
|
||||||
</button>
|
</button>
|
||||||
<div class="ml-2 text-xs uppercase tracking-wide text-text-secondary">Now playing</div>
|
<div class="ml-2 text-xs uppercase tracking-wide text-text-secondary">Now playing</div>
|
||||||
|
<!-- Full-screen Now Playing carries the same kebab as Android's
|
||||||
|
NowPlayingScreen (hideQueueActions — the menu's track IS the one
|
||||||
|
playing), so Start radio / Add to playlist / Hide etc. are reachable
|
||||||
|
here, not just from the mini-player bar. -->
|
||||||
|
{#if current}
|
||||||
|
<div class="ml-auto">
|
||||||
|
<TrackMenu track={current} hideQueueActions />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if !current}
|
{#if !current}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ vi.mock('$lib/api/home', () => ({
|
|||||||
most_played_tracks: [],
|
most_played_tracks: [],
|
||||||
rediscover_albums: [],
|
rediscover_albums: [],
|
||||||
rediscover_artists: [],
|
rediscover_artists: [],
|
||||||
last_played_artists: []
|
last_played_artists: [],
|
||||||
|
you_might_like_albums: [],
|
||||||
|
you_might_like_artists: []
|
||||||
},
|
},
|
||||||
isPending: false,
|
isPending: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { pageTitle } from '$lib/branding';
|
import { pageTitle } from '$lib/branding';
|
||||||
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
|
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
@@ -12,12 +13,15 @@
|
|||||||
deletePlaylist,
|
deletePlaylist,
|
||||||
removePlaylistTrack,
|
removePlaylistTrack,
|
||||||
reorderPlaylist,
|
reorderPlaylist,
|
||||||
refreshSystem
|
refreshSystem,
|
||||||
|
systemShuffle
|
||||||
} from '$lib/api/playlists';
|
} from '$lib/api/playlists';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
import { user } from '$lib/auth/store.svelte';
|
import { user } from '$lib/auth/store.svelte';
|
||||||
import { errCode, errMessage } from '$lib/api/errors';
|
import { errCode, errMessage } from '$lib/api/errors';
|
||||||
import { playQueue } from '$lib/player/store.svelte';
|
import { playQueue } from '$lib/player/store.svelte';
|
||||||
|
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
||||||
|
import { systemRebuilt } from '$lib/serverEvents.svelte';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
@@ -61,16 +65,31 @@
|
|||||||
|
|
||||||
function onPlay(position: number) {
|
function onPlay(position: number) {
|
||||||
if (!playlistQuery?.data) return;
|
if (!playlistQuery?.data) return;
|
||||||
|
const data = playlistQuery.data;
|
||||||
// Skip rows where track_id is null (track was removed from library).
|
// Skip rows where track_id is null (track was removed from library).
|
||||||
const live = playlistQuery.data.tracks
|
const live = data.tracks.filter((t) => t.track_id !== null).map(toTrackRef);
|
||||||
.filter((t) => t.track_id !== null)
|
|
||||||
.map(toTrackRef);
|
|
||||||
// Find which `live` index corresponds to the clicked position. Some
|
// Find which `live` index corresponds to the clicked position. Some
|
||||||
// positions in the original list may be unavailable (filtered out),
|
// positions in the original list may be unavailable (filtered out),
|
||||||
// so the index in the playable list != the playlist position.
|
// so the index in the playable list != the playlist position.
|
||||||
const clickedTrackID = playlistQuery.data.tracks.find((t) => t.position === position)?.track_id;
|
const clickedTrackID = data.tracks.find((t) => t.position === position)?.track_id;
|
||||||
const startIdx = live.findIndex((t) => t.id === clickedTrackID);
|
const startIdx = live.findIndex((t) => t.id === clickedTrackID);
|
||||||
playQueue(live, Math.max(0, startIdx));
|
// #968: a system playlist played from its detail page is tagged with its
|
||||||
|
// source (so play_started advances the rotation, matching the home tile and
|
||||||
|
// the Android detail screen) and gets the self-heal closure so a stale
|
||||||
|
// snapshot re-pulls on total failure.
|
||||||
|
const variant = data.system_variant;
|
||||||
|
if (variant != null) {
|
||||||
|
playQueue(live, Math.max(0, startIdx), {
|
||||||
|
source: variant,
|
||||||
|
refetch: systemPlaylistRefetch({
|
||||||
|
variant,
|
||||||
|
playlistId: id,
|
||||||
|
perArtist: data.seed_artist_id != null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
playQueue(live, Math.max(0, startIdx));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTrackRef(t: {
|
function toTrackRef(t: {
|
||||||
@@ -158,6 +177,40 @@
|
|||||||
refreshingSystem = false;
|
refreshingSystem = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- #980: stale-view banner for an open system-playlist detail page ---
|
||||||
|
// The daily rebuild rotates the playlist id, so this page's cached data goes
|
||||||
|
// stale and can't be refetched in place (the old id 404s). Watch the global
|
||||||
|
// rebuild counter; when it advances while we're on a system playlist, offer a
|
||||||
|
// Refresh that re-resolves the variant to the fresh playlist.
|
||||||
|
const isSystemPlaylist = $derived(playlistQuery?.data?.system_variant != null);
|
||||||
|
// Baseline starts at the current count so a rebuild earlier this session
|
||||||
|
// doesn't flash the banner on open; re-synced on navigation below.
|
||||||
|
let acknowledgedRebuild = $state(systemRebuilt.count);
|
||||||
|
$effect(() => {
|
||||||
|
id; // re-sync the baseline whenever we land on a different playlist
|
||||||
|
acknowledgedRebuild = untrack(() => systemRebuilt.count);
|
||||||
|
});
|
||||||
|
const staleSystemView = $derived(
|
||||||
|
isSystemPlaylist && systemRebuilt.count > acknowledgedRebuild
|
||||||
|
);
|
||||||
|
|
||||||
|
let reloadingStale = $state(false);
|
||||||
|
async function onReloadStale() {
|
||||||
|
const variant = playlistQuery?.data?.system_variant;
|
||||||
|
if (!variant) return;
|
||||||
|
reloadingStale = true;
|
||||||
|
try {
|
||||||
|
// The rebuild minted a new playlist id for this variant; resolve it and
|
||||||
|
// navigate there so the page loads the fresh mix.
|
||||||
|
const fresh = await systemShuffle(variant);
|
||||||
|
await goto(`/playlists/${fresh.id}`);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
pushToast(`Couldn’t load the refreshed mix: ${errCode(e)}`, 'error');
|
||||||
|
} finally {
|
||||||
|
reloadingStale = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -171,6 +224,24 @@
|
|||||||
<ApiErrorBanner error={playlistQuery.error} onRetry={() => playlistQuery.refetch()} />
|
<ApiErrorBanner error={playlistQuery.error} onRetry={() => playlistQuery.refetch()} />
|
||||||
{:else if playlistQuery?.data}
|
{:else if playlistQuery?.data}
|
||||||
{@const pl = playlistQuery.data}
|
{@const pl = playlistQuery.data}
|
||||||
|
{#if staleSystemView}
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
class="mb-4 flex items-center justify-between gap-3 rounded-md border border-accent/40
|
||||||
|
bg-accent/10 px-4 py-2.5 text-sm"
|
||||||
|
>
|
||||||
|
<span class="text-text-primary">This mix was refreshed since you opened it.</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onReloadStale}
|
||||||
|
disabled={reloadingStale}
|
||||||
|
class="flex-shrink-0 rounded-md bg-action-secondary px-3 py-1 text-xs font-medium
|
||||||
|
text-action-fg disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{reloadingStale ? 'Refreshing…' : 'Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if !editing}
|
{#if !editing}
|
||||||
<header class="mb-6 flex items-start gap-4">
|
<header class="mb-6 flex items-start gap-4">
|
||||||
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-hover">
|
<div class="h-32 w-32 flex-shrink-0 overflow-hidden rounded-md bg-surface-hover">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user