Compare commits
33 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 |
@@ -2,8 +2,8 @@ name: release
|
||||
|
||||
# Builds and pushes the minstrel container image to the Gitea registry.
|
||||
#
|
||||
# push to main → :main and :latest (no APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (APK bundled)
|
||||
# push to main → :main and :latest (latest-release APK bundled)
|
||||
# push tag vYYYY.MM.DD → :vYYYY.MM.DD and :latest (freshly-built APK bundled)
|
||||
# workflow_dispatch → manual trigger (same rules based on the ref)
|
||||
#
|
||||
# Release model: per-day CalVer tags (no trailing patch digit). The day's
|
||||
@@ -20,6 +20,14 @@ name: release
|
||||
# happens in the same android-release job, so the Release-page download
|
||||
# link and the in-image bundled APK are both populated atomically.
|
||||
#
|
||||
# :latest always carries an APK. Because every main push also moves
|
||||
# :latest (not just tags), a main build with no APK would silently strip
|
||||
# the in-app update channel off :latest until the next release. So on
|
||||
# non-tag builds image-release pulls the MOST RECENT release's signed APK
|
||||
# and reconstructs its exact versionName (tag + commit-count, the same
|
||||
# formula android-release bakes in) for the version sidecar — no rebuild,
|
||||
# just rebundle. Tag builds keep bundling their own freshly-built APK.
|
||||
#
|
||||
# Android testing (lint + detekt + unit tests, debug APK upload on main)
|
||||
# lives in android.yml and runs independently on every push.
|
||||
|
||||
@@ -181,6 +189,12 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history + tags so non-tag :latest builds can resolve the
|
||||
# latest release tag's commit count and reconstruct the bundled
|
||||
# APK's exact versionName (see "Bundle latest release APK" below).
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Detect buildable project
|
||||
id: guard
|
||||
@@ -221,9 +235,8 @@ jobs:
|
||||
| docker login git.fabledsword.com -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Download signed APK artifact
|
||||
# Tag pushes only — android-release just produced this. Main
|
||||
# pushes skip and the image ships with empty client/ (the
|
||||
# /api/client/version endpoint then returns 404 by design).
|
||||
# Tag pushes only — android-release just produced this. Non-tag
|
||||
# builds take the "Bundle latest release APK" path below instead.
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
@@ -247,6 +260,42 @@ jobs:
|
||||
echo "${APK_VERSION_NAME}" > client/minstrel.apk.version
|
||||
ls -lh client/
|
||||
|
||||
- name: Bundle latest release APK (non-tag :latest builds)
|
||||
# Main pushes don't build an APK, but they DO move :latest — so
|
||||
# without this the in-app update channel would vanish from :latest
|
||||
# until the next tag. Pull the most-recent release's signed APK and
|
||||
# reconstruct its exact versionName (${TAG#v}.$(git rev-list --count
|
||||
# TAG) — identical to android-release's formula) so the version
|
||||
# sidecar the server hands clients matches the installed build.
|
||||
# Degrades to an empty client/ (404 update channel) — never a wrong
|
||||
# version — if no release / APK asset / tag-count can be resolved.
|
||||
if: steps.guard.outputs.ready == 'true' && !startsWith(github.ref, 'refs/tags/v')
|
||||
shell: bash
|
||||
env:
|
||||
CI_TOKEN: ${{ secrets.CI_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
REPO="${GITHUB_REPOSITORY}"
|
||||
REL_JSON="$(curl -fsSL -H "Authorization: token ${CI_TOKEN}" \
|
||||
"https://git.fabledsword.com/api/v1/repos/${REPO}/releases/latest" || true)"
|
||||
if [ -z "${REL_JSON}" ]; then
|
||||
echo "::notice::no published release — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
TAG="$(printf '%s' "${REL_JSON}" | grep -oP '"tag_name":\s*"\K[^"]+' | head -1)"
|
||||
APK_URL="$(printf '%s' "${REL_JSON}" | grep -oP '"browser_download_url":\s*"\K[^"]+' | grep -E '\.apk$' | head -1)"
|
||||
if [ -z "${TAG}" ] || [ -z "${APK_URL}" ]; then
|
||||
echo "::notice::latest release '${TAG:-?}' has no APK asset — image ships without bundled APK"; exit 0
|
||||
fi
|
||||
COUNT="$(git rev-list --count "${TAG}" 2>/dev/null || true)"
|
||||
if [ -z "${COUNT}" ]; then
|
||||
echo "::notice::could not resolve commit count for ${TAG} (tag not fetched?) — skipping APK bundle"; exit 0
|
||||
fi
|
||||
VERSION_NAME="${TAG#v}.${COUNT}"
|
||||
curl -fsSL -H "Authorization: token ${CI_TOKEN}" -o client/minstrel.apk "${APK_URL}"
|
||||
echo "${VERSION_NAME}" > client/minstrel.apk.version
|
||||
echo "::notice::bundled release APK ${TAG} as version ${VERSION_NAME}"
|
||||
ls -lh client/
|
||||
|
||||
- name: Build and push
|
||||
if: steps.guard.outputs.ready == 'true'
|
||||
run: |
|
||||
|
||||
@@ -4,7 +4,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
|
||||
> State and intelligence belong on the server, not the client.
|
||||
|
||||
<!-- TODO: screenshot of the home page -->
|
||||
<a href="docs/screenshots/home.png"><img src="docs/screenshots/home.png" width="820" alt="Minstrel home — your library at a glance"></a>
|
||||
|
||||
## Highlights
|
||||
|
||||
@@ -13,7 +13,7 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||
- **Flutter mobile client in flight.** Tracking issue [#356](https://git.fabledsword.com/bvandeusen/minstrel/issues/356).
|
||||
- **Native Android client, shipped with the server.** The signed APK is bundled into every image and attached to each [release](https://git.fabledsword.com/bvandeusen/minstrel/releases) — sideload it once, then the app self-updates straight from your own server (no app store, no separate download to track).
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -24,10 +24,18 @@ services:
|
||||
image: git.fabledsword.com/bvandeusen/minstrel:latest
|
||||
ports: ['4533:4533']
|
||||
volumes:
|
||||
# Your music library. Point ./music at wherever your audio files
|
||||
# live. Mounted read-only — Minstrel never writes to your library.
|
||||
- ./music:/music:ro
|
||||
- minstrel-data:/data
|
||||
# Generated data: playlist cover collages, artist art, caches.
|
||||
# The path must match MINSTREL_STORAGE_DATA_DIR, which the image
|
||||
# sets to /app/data — keep this mount on /app/data or your cache
|
||||
# won't survive a container recreate.
|
||||
- minstrel-data:/app/data
|
||||
environment:
|
||||
MINSTREL_DATABASE_URL: postgres://minstrel:minstrel@db:5432/minstrel?sslmode=disable
|
||||
# Colon-separated library roots to scan; must match the container
|
||||
# path of the read-only music mount above (/music here).
|
||||
MINSTREL_LIBRARY_SCAN_PATHS: /music
|
||||
depends_on: [db]
|
||||
|
||||
@@ -37,6 +45,8 @@ services:
|
||||
POSTGRES_USER: minstrel
|
||||
POSTGRES_PASSWORD: minstrel
|
||||
POSTGRES_DB: minstrel
|
||||
# Postgres data dir — users, likes, play history, sessions, settings.
|
||||
# The one volume you must never lose; back it up with pg_dump.
|
||||
volumes: [pgdata:/var/lib/postgresql/data]
|
||||
|
||||
volumes:
|
||||
@@ -48,7 +58,29 @@ volumes:
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
After the stack is up, visit `http://localhost:4533/register` and create your admin account. The first user to register on a fresh instance is automatically marked as the administrator; subsequent users can register through the same form (or via invite tokens generated from the admin Users panel, depending on how you configure registration).
|
||||
## First run
|
||||
|
||||
With the stack up, a handful of in-app steps get you to a working library. Use your own host in place of `localhost` if you're reaching the server over a LAN/VPN address (plain `http://` is fine — no TLS required).
|
||||
|
||||
**1. Create your admin account.** Visit `http://localhost:4533/register`. The first account on a fresh instance is automatically the administrator; later users join through the same form or an invite token (step 5).
|
||||
|
||||
<a href="docs/screenshots/register.png"><img src="docs/screenshots/register.png" width="320" alt="Creating the first (admin) account on a fresh instance"></a>
|
||||
|
||||
**2. Let the first library scan finish.** `scan_on_startup` is on by default, so Minstrel walks your mounted library on boot and imports artists, albums, and tracks — no button to press. Watch progress (and re-scan any time) on the **Admin** page (`/admin`); the scan runs in stages and is incremental, so later restarts only pick up what changed.
|
||||
|
||||
<a href="docs/screenshots/library-scan.png"><img src="docs/screenshots/library-scan.png" width="820" alt="The Admin page, where the library scan runs and reports progress"></a>
|
||||
|
||||
**3. (Optional) Name the instance and wire up integrations.** In admin **Settings → Integrations** (`/admin/integrations`), add a ListenBrainz token (scrobbling + similarity radio) and/or a Lidarr URL + API key (the request flow). These live in the UI and apply without a restart; the display name can also be set via `MINSTREL_BRANDING_APP_NAME`.
|
||||
|
||||
<a href="docs/screenshots/integrations.png"><img src="docs/screenshots/integrations.png" width="820" alt="ListenBrainz and Lidarr integration cards in admin Settings"></a>
|
||||
|
||||
**4. Install the Android app.** Open **Settings** (`/settings`) and use the *Install the Android app* card to download the APK that ships inside this server image, then sign in with the same account. From then on the app self-updates straight from your server.
|
||||
|
||||
<a href="docs/screenshots/android-download.png"><img src="docs/screenshots/android-download.png" width="820" alt="The "Install the Android app" download card in Settings"></a>
|
||||
|
||||
**5. Invite the rest of the household.** From admin **Users** (`/admin/users`), generate an invite token (or enable open registration). Each person gets their own account, so likes, play history, and recommendations stay per-user.
|
||||
|
||||
<a href="docs/screenshots/invite-users.png"><img src="docs/screenshots/invite-users.png" width="820" alt="Generating an invite token in admin Users"></a>
|
||||
|
||||
For the full configuration surface, see [`config.example.yaml`](./config.example.yaml).
|
||||
|
||||
@@ -57,7 +89,7 @@ For the full configuration surface, see [`config.example.yaml`](./config.example
|
||||
Most operators only need the env vars in the quickstart above. A few extras worth knowing:
|
||||
|
||||
- `MINSTREL_BRANDING_APP_NAME` — rename the instance ("Family Jukebox", "Office Music"). Surfaces in the header, browser tab, and OG share previews.
|
||||
- `MINSTREL_STORAGE_DATA_DIR` — defaults to `./data`. Holds playlist cover collages and other generated artefacts.
|
||||
- `MINSTREL_STORAGE_DATA_DIR` — where generated artefacts (playlist cover collages, artist art, caches) are written. The container image sets this to `/app/data`, which is why the quickstart mounts the `minstrel-data` volume there.
|
||||
- `MINSTREL_LIBRARY_SCAN_PATHS` — colon-separated list of music library roots to scan. Supports multiple roots (`/music:/podcasts`).
|
||||
|
||||
ListenBrainz integration (per-user scrobble + similarity tokens) and Lidarr integration (URL + API key) are configured through the admin Settings UI rather than env vars or yaml — per Minstrel's "config in UI" rule, integration settings live where operators can edit them without restarting.
|
||||
@@ -66,8 +98,13 @@ Most operational keys have a `MINSTREL_<SECTION>_<FIELD>` env override. Recommen
|
||||
|
||||
## Updating
|
||||
|
||||
- `:main` — rolling, follows the dev branch's tested tip. Recommended only for the operator who's running an upstream-watching deployment.
|
||||
- `:v1.0.x` — pinned releases. Recommended default. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
Image tags (`git.fabledsword.com/bvandeusen/minstrel:<tag>`):
|
||||
|
||||
- `:latest` — the newest blessed image. Moves on every `main` push **and** every release. Recommended for most operators.
|
||||
- `:vYYYY.MM.DD` — immutable per-day release tags. Pin one of these for a deployment you don't want moving under you. (Per-day CalVer — no trailing patch digit; a same-day re-cut moves the tag forward.)
|
||||
- `:main` — the rolling post-merge tip. Same image as `:latest` at push time; choose it if you want to track `main` explicitly rather than the release line.
|
||||
|
||||
Every `:latest` and every `:vYYYY.MM.DD` bundles the current signed Android APK, so the in-app update channel is always live. Database migrations run automatically at startup; rollbacks require restoring a Postgres dump.
|
||||
|
||||
## Specs
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -139,6 +140,7 @@ class HomeViewModel @Inject constructor(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val player: com.fabledsword.minstrel.player.PlayerController,
|
||||
private val shuffleSource: com.fabledsword.minstrel.cache.ShuffleSource,
|
||||
private val eventsStream: com.fabledsword.minstrel.events.EventsStream,
|
||||
networkStatus: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -167,6 +169,15 @@ class HomeViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
||||
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
||||
// and You-might-like rows reflect the new snapshot without a manual
|
||||
// reload. Mirrors the web SSE consumer.
|
||||
viewModelScope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind == "playlist.system_rebuilt" }
|
||||
.collect { refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Holds a WiFi + CPU wake lock for the lifetime of an active UPnP cast.
|
||||
*
|
||||
* While casting, the wrapped ExoPlayer is paused, so its automatic
|
||||
* `WAKE_MODE_NETWORK` locks are released and nothing keeps the phone's radio
|
||||
* awake. On a locked, on-battery phone the WiFi enters power-save within
|
||||
* seconds and the CPU dozes; that stalls the 1 Hz liveness poll to the
|
||||
* renderer and the queue-extend calls to the server. The poll then trips the
|
||||
* drop threshold and playback falls back to a phone that *also* has no
|
||||
* network — silence, while the renderer was streaming fine the whole time.
|
||||
*
|
||||
* A high-performance / low-latency [WifiManager.WifiLock] keeps the radio out
|
||||
* of power-save, and a partial [PowerManager.WakeLock] keeps the poll
|
||||
* coroutine scheduled. Both are acquired when a route goes active and released
|
||||
* the moment it drops or the user switches back to the phone.
|
||||
*
|
||||
* All methods are idempotent (`setReferenceCounted(false)` + held-checks) and
|
||||
* synchronized, so they are safe to call from any thread.
|
||||
*/
|
||||
class CastNetworkLock(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
private val wifiLock: WifiManager.WifiLock? =
|
||||
(appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)
|
||||
?.createWifiLock(wifiLockMode(), LOCK_TAG)
|
||||
?.apply { setReferenceCounted(false) }
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock? =
|
||||
(appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager)
|
||||
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG)
|
||||
?.apply { setReferenceCounted(false) }
|
||||
|
||||
@Synchronized
|
||||
fun acquire() {
|
||||
runCatching { wifiLock?.takeUnless { it.isHeld }?.acquire() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wifi acquire failed") }
|
||||
runCatching { wakeLock?.takeUnless { it.isHeld }?.acquire() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wake acquire failed") }
|
||||
Timber.w(
|
||||
"CastNetworkLock acquired (wifi=%s wake=%s)",
|
||||
wifiLock?.isHeld, wakeLock?.isHeld,
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun release() {
|
||||
runCatching { wifiLock?.takeIf { it.isHeld }?.release() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wifi release failed") }
|
||||
runCatching { wakeLock?.takeIf { it.isHeld }?.release() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wake release failed") }
|
||||
Timber.w("CastNetworkLock released")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LOCK_TAG = "minstrel:upnp-cast"
|
||||
|
||||
// WIFI_MODE_FULL_LOW_LATENCY (API 29+) both disables power-save and
|
||||
// lowers latency; FULL_HIGH_PERF is the pre-Q equivalent for keeping
|
||||
// the radio fully awake.
|
||||
fun wifiLockMode(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
-3
@@ -10,6 +10,8 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
@@ -63,6 +65,8 @@ class MinstrelForwardingPlayer(
|
||||
private val delegate: Player,
|
||||
private val holder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val castNetworkLock: CastNetworkLock,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
private val onDrop: (routeName: String) -> Unit,
|
||||
) : ForwardingPlayer(delegate) {
|
||||
|
||||
@@ -116,6 +120,20 @@ class MinstrelForwardingPlayer(
|
||||
scope.launch {
|
||||
holder.active.collect { active -> onActiveChanged(active) }
|
||||
}
|
||||
scope.launch {
|
||||
// When the phone's own network recovers, reconcile a held cast
|
||||
// session immediately instead of waiting up to POLL_INTERVAL_MS:
|
||||
// the renderer kept playing, so one round-trip re-confirms it and
|
||||
// resyncs the cursor/position. Only a transition *into* Healthy
|
||||
// while a route is active matters.
|
||||
var prev = networkStatus.state.value
|
||||
networkStatus.state.collect { next ->
|
||||
if (next == ServerHealth.Healthy && prev != ServerHealth.Healthy && isRemote()) {
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
prev = next
|
||||
}
|
||||
}
|
||||
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
||||
// addObserver requires it. The observer just trySend's to the channel.
|
||||
handler.post {
|
||||
@@ -438,6 +456,11 @@ class MinstrelForwardingPlayer(
|
||||
lastNotifiedTrackIdx = -1
|
||||
if (active != null) {
|
||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||
// Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
|
||||
// about to be paused (releasing its WAKE_MODE_NETWORK locks), so
|
||||
// without this the radio power-saves on a locked screen and the
|
||||
// poll below starves -- see [CastNetworkLock].
|
||||
castNetworkLock.acquire()
|
||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||
// simultaneously with the remote renderer. handler.post targets the
|
||||
// application looper, so this runs on the same thread that processes
|
||||
@@ -447,20 +470,26 @@ class MinstrelForwardingPlayer(
|
||||
handler.post { delegate.pause() }
|
||||
pollJob = scope.launch { pollLoop(active) }
|
||||
} else {
|
||||
castNetworkLock.release()
|
||||
remoteState.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||
var networkDropSuppressed = false
|
||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||
val outcome = runCatching { pollOnce(active) }
|
||||
if (outcome.isSuccess) {
|
||||
remoteState.recordPollSuccess()
|
||||
networkDropSuppressed = false
|
||||
} else if (remoteState.recordPollFailure()) {
|
||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
return
|
||||
if (networkStatus.state.value == ServerHealth.Healthy) {
|
||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
return
|
||||
}
|
||||
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
|
||||
}
|
||||
// Race the normal cadence against any external wake (activity
|
||||
// resume). Whichever wins continues to the next pollOnce.
|
||||
@@ -471,6 +500,30 @@ class MinstrelForwardingPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The poll-failure threshold tripped, but the phone's *own* network is down
|
||||
* (state is not [ServerHealth.Healthy]) -- so this is "we can't see the
|
||||
* renderer right now," not "the renderer died." A UPnP renderer streams
|
||||
* autonomously and is almost certainly still playing; meanwhile a local
|
||||
* fallback couldn't reach the server either, so dropping would only blast
|
||||
* local audio out of a pocketed phone while the speaker keeps going. Hold
|
||||
* the route and keep polling -- a successful poll once the network returns
|
||||
* reconciles to the renderer's real state. Clear the failure streak so
|
||||
* recovery re-evaluates the renderer from scratch instead of re-dropping on
|
||||
* the first post-recovery hiccup. Returns the (latched) suppression flag so
|
||||
* the rationale logs once per outage, not every tick.
|
||||
*/
|
||||
private fun suppressDropForNetwork(active: ActiveUpnp, alreadySuppressed: Boolean): Boolean {
|
||||
if (!alreadySuppressed) {
|
||||
Timber.w(
|
||||
"UPnP drop suppressed for %s -- phone network %s, holding route",
|
||||
active.routeName, networkStatus.state.value,
|
||||
)
|
||||
}
|
||||
remoteState.recordPollSuccess() // clear streak; not a renderer failure
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll tick: read position + transport state from Sonos, apply to
|
||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||
|
||||
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -63,6 +65,7 @@ class PlayerController @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
private val radio: RadioController,
|
||||
private val playlists: PlaylistsRepository,
|
||||
private val playerFactory: PlayerFactory,
|
||||
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
@@ -111,6 +114,13 @@ class PlayerController @Inject constructor(
|
||||
*/
|
||||
private var lastEvaluatedItemIndex: Int = -1
|
||||
|
||||
/**
|
||||
* #968: self-heal budget — re-pulls of a stale refreshable queue since
|
||||
* the last successful play. Capped so a still-broken refresh can't loop;
|
||||
* reset when any track loads with real audio.
|
||||
*/
|
||||
private var selfHealAttempts = 0
|
||||
|
||||
/**
|
||||
* Stable queue snapshot kept in sync with the player's MediaItems —
|
||||
* the player's own getMediaItem(index) returns Media3 types; we keep
|
||||
@@ -192,6 +202,20 @@ class PlayerController @Inject constructor(
|
||||
controller.play()
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the local cursor to [index] + [positionMs] WITHOUT auto-playing.
|
||||
* Used by the UPnP adopt path: when attaching to a renderer that's already
|
||||
* playing our queue, we sync the local cursor to the renderer's current
|
||||
* track/position so skip/seek and the UI map onto its live session — the
|
||||
* renderer keeps the audio, the local player stays paused. Unlike
|
||||
* [seekToIndex] this issues no play().
|
||||
*/
|
||||
fun moveCursorTo(index: Int, positionMs: Long) {
|
||||
val controller = mediaController ?: return
|
||||
if (index !in queueRefs.indices) return
|
||||
runOnControllerThread(controller) { controller.seekTo(index, positionMs) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the queue with [tracks] starting at [initialIndex].
|
||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
||||
@@ -337,7 +361,12 @@ class PlayerController @Inject constructor(
|
||||
if (current == null || upnpEngaged) return
|
||||
val duration = controller.duration
|
||||
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
|
||||
if (!isZeroDuration) return
|
||||
if (!isZeroDuration) {
|
||||
// A track loaded with real audio — clear the self-heal budget so
|
||||
// a later stale queue can recover again.
|
||||
selfHealAttempts = 0
|
||||
return
|
||||
}
|
||||
playbackErrorEventsChannel.trySend(
|
||||
PlaybackErrorEvent(
|
||||
trackId = current.id,
|
||||
@@ -353,6 +382,58 @@ class PlayerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #968: the queue is fully unplayable (every track failed to load). If it
|
||||
* came from a refreshable source — a system playlist (bare-variant source)
|
||||
* or radio ("radio:<seed>") — the snapshot is probably stale (app/tab left
|
||||
* open across the daily rebuild); re-pull it and resume instead of stopping.
|
||||
* Bounded by [selfHealAttempts]. Album / artist / user-playlist / offline
|
||||
* sources have nothing to refresh — stop.
|
||||
*/
|
||||
private fun selfHealOrStop(controller: MediaController) {
|
||||
val source = controller.currentMediaItem
|
||||
?.mediaMetadata?.extras?.getString(MINSTREL_SOURCE_KEY)
|
||||
if (source == null || selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) {
|
||||
controller.stop()
|
||||
return
|
||||
}
|
||||
when {
|
||||
source.startsWith("radio:") -> {
|
||||
selfHealAttempts++
|
||||
scope.launch { selfHealRadio(source.removePrefix("radio:")) }
|
||||
}
|
||||
!source.contains(':') -> { // bare variant = refreshable system playlist
|
||||
selfHealAttempts++
|
||||
scope.launch { selfHealSystem(source) }
|
||||
}
|
||||
else -> controller.stop()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun selfHealSystem(variant: String) {
|
||||
val refs = runCatching { playlists.systemShuffle(variant).tracks.toPlayableTrackRefs() }
|
||||
.getOrDefault(emptyList())
|
||||
if (refs.isEmpty()) {
|
||||
stopOnControllerThread()
|
||||
return
|
||||
}
|
||||
setQueue(refs, initialIndex = 0, source = variant)
|
||||
}
|
||||
|
||||
private suspend fun selfHealRadio(seedTrackId: String) {
|
||||
val refs = runCatching { radio.seed(seedTrackId) }.getOrDefault(emptyList())
|
||||
if (refs.isEmpty()) {
|
||||
stopOnControllerThread()
|
||||
return
|
||||
}
|
||||
setQueue(refs, initialIndex = 0, source = "radio:$seedTrackId")
|
||||
}
|
||||
|
||||
private fun stopOnControllerThread() {
|
||||
val controller = mediaController ?: return
|
||||
runOnControllerThread(controller) { controller.stop() }
|
||||
}
|
||||
|
||||
// ── Internal: async connect + Listener-driven UI state sync ──────────
|
||||
|
||||
private suspend fun connectAndObserve() {
|
||||
@@ -403,6 +484,17 @@ class PlayerController @Inject constructor(
|
||||
detail = error.message,
|
||||
),
|
||||
)
|
||||
// A failed load leaves the player IDLE; advance past the bad
|
||||
// track and re-prepare so one unplayable item doesn't strand
|
||||
// playback (mirrors the zero_duration skip). At the end of a
|
||||
// fully-unplayable queue, try to self-heal a stale refreshable
|
||||
// source before stopping.
|
||||
if (controller.hasNextMediaItem()) {
|
||||
controller.seekToNextMediaItem()
|
||||
controller.prepare()
|
||||
} else {
|
||||
selfHealOrStop(controller)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaItemTransition(
|
||||
@@ -749,6 +841,10 @@ class PlayerController @Inject constructor(
|
||||
// they don't need this poll.
|
||||
private const val POSITION_POLL_INTERVAL_MS = 500L
|
||||
|
||||
// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
|
||||
// the next successful play) so a still-broken refresh can't loop.
|
||||
private const val MAX_SELF_HEAL_ATTEMPTS = 1
|
||||
|
||||
/**
|
||||
* Structured playback failure event for [PlaybackErrorReporter]. Drives
|
||||
* both the user-facing snackbar ("Couldn't play X — skipping") and the
|
||||
|
||||
@@ -81,6 +81,8 @@ class PlayerFactory @Inject constructor(
|
||||
delegate = exo,
|
||||
holder = activeUpnpHolder,
|
||||
remoteState = remoteState,
|
||||
castNetworkLock = CastNetworkLock(context),
|
||||
networkStatus = serverHealth,
|
||||
onDrop = { name -> emitDrop(name) },
|
||||
)
|
||||
}
|
||||
|
||||
+130
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
@@ -172,6 +174,31 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
scope.launch { observeQueueChangesForSonosResync() }
|
||||
scope.launch { observeIdleRevertWhileUpnp() }
|
||||
scope.launch { observeSelectedRouteDisappearance() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert the UPnP selection to the phone when the selected renderer leaves
|
||||
* discovery (powered off / off the LAN) while we are NOT actively casting
|
||||
* to it. Without this the selection could pin to a ghost route, so a later
|
||||
* "play" would target a speaker that's gone. An *active* cast is left alone
|
||||
* -- its own poll loop arbitrates liveness and won't drop on a mere
|
||||
* discovery gap. Routes leave discovery via [UpnpDiscoveryController]'s
|
||||
* post-picker-scan prune; this reacts to the resulting routes change.
|
||||
*/
|
||||
private suspend fun observeSelectedRouteDisappearance() {
|
||||
upnpDiscovery.routes.collect { routes ->
|
||||
val selected = selectedUpnpRouteIdInternal.value
|
||||
val present = routes.any { it.id == selected }
|
||||
val casting = activeUpnpHolder.active.value?.routeId == selected
|
||||
if (selected != null && !present && !casting) {
|
||||
Timber.w(
|
||||
"Output: selected UPnP route %s left discovery; reverting to phone",
|
||||
selected,
|
||||
)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -541,6 +568,13 @@ class OutputPickerController @Inject constructor(
|
||||
// local audio while we queue up Sonos.
|
||||
playerController.pause()
|
||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||
// If the renderer is already playing this exact queue (a "stray"
|
||||
// session we got disconnected from), attach to it in place instead of
|
||||
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
||||
// live queue. Falls through to the clear+reload path when it isn't ours.
|
||||
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
||||
return@withLock
|
||||
}
|
||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||
// silently while target is set but active is null -- the user's premature
|
||||
// taps don't hit Sonos's stale state from a prior session.
|
||||
@@ -567,6 +601,102 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer's current position + transport state, validated as an
|
||||
* adoptable continuation of our local queue: same track id at the same
|
||||
* 1-based index. Null means "not ours / not playing" -> caller reloads.
|
||||
*/
|
||||
private data class AdoptPlan(
|
||||
val localIndex: Int,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
val trackUri: String,
|
||||
val trackNumber: Int,
|
||||
val playing: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Attach to a renderer that is *already* playing our queue (a session the
|
||||
* phone got disconnected from but the speaker kept streaming) instead of
|
||||
* clearing + reloading it. Probes the renderer's live state, and if it's
|
||||
* mid-playback on the same track id at the same queue index, syncs the
|
||||
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
|
||||
* skips a beat, and skip/seek now drive its live queue. Returns false (and
|
||||
* the caller does the full clear+reload) when the renderer isn't playing
|
||||
* our queue. The local player is already paused by the caller.
|
||||
*/
|
||||
private suspend fun tryAdoptRunningSession(
|
||||
transport: AVTransportClient,
|
||||
rendering: RenderingControlClient?,
|
||||
route: OutputRoute,
|
||||
localQueue: List<TrackRef>,
|
||||
): Boolean {
|
||||
val plan = probeAdoptable(transport, route, localQueue) ?: return false
|
||||
Timber.w(
|
||||
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
||||
route.name, plan.trackNumber, plan.positionMs,
|
||||
)
|
||||
// active is still null here, so this aligns the *local* cursor without
|
||||
// emitting SOAP; the holder.set below then starts the poll loop, which
|
||||
// reconciles to the renderer's advancing position from here.
|
||||
playerController.moveCursorTo(plan.localIndex, plan.positionMs)
|
||||
remoteState.applyPositionInfo(
|
||||
positionMs = plan.positionMs,
|
||||
durationMs = plan.durationMs,
|
||||
trackUri = plan.trackUri,
|
||||
trackNumber = plan.trackNumber,
|
||||
)
|
||||
remoteState.setPlayIntent(plan.playing)
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
routeId = route.id,
|
||||
routeName = route.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun probeAdoptable(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
localQueue: List<TrackRef>,
|
||||
): AdoptPlan? {
|
||||
val probe = runCatching {
|
||||
transport.getTransportInfo() to transport.getPositionInfo()
|
||||
}.getOrElse {
|
||||
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
|
||||
return null
|
||||
}
|
||||
val (info, pos) = probe
|
||||
val idx0 = pos.track - 1
|
||||
val playingId = trackIdFromStreamUrl(pos.trackUri)
|
||||
val midPlayback =
|
||||
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
||||
val aligned = midPlayback && playingId != null &&
|
||||
idx0 in localQueue.indices && localQueue[idx0].id == playingId
|
||||
return if (aligned) {
|
||||
AdoptPlan(
|
||||
localIndex = idx0,
|
||||
positionMs = pos.relTimeMs,
|
||||
durationMs = pos.trackDurationMs,
|
||||
trackUri = pos.trackUri,
|
||||
trackNumber = pos.track,
|
||||
playing = info.state == TransportState.PLAYING,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
|
||||
private fun trackIdFromStreamUrl(url: String): String? {
|
||||
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
|
||||
val i = segments.indexOf("tracks")
|
||||
return if (i >= 0) segments.getOrNull(i + 1) else null
|
||||
}
|
||||
|
||||
private suspend fun loadQueueOnSonos(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
|
||||
+39
-1
@@ -8,6 +8,7 @@ import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -17,6 +18,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
@@ -58,6 +60,12 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||
|
||||
// Wall-clock of the last NOTIFY / M-SEARCH response per route id. Drives
|
||||
// post-active-scan pruning so a powered-off renderer leaves the picker
|
||||
// (and can't pin a stale selection) instead of lingering for the process
|
||||
// lifetime -- upsert only ever added before.
|
||||
private val lastSeenMs = ConcurrentHashMap<String, Long>()
|
||||
|
||||
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||
|
||||
@@ -78,7 +86,31 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
* that just opened).
|
||||
*/
|
||||
fun upgradeDiscovery() {
|
||||
appScope.launch { ssdp.requestActiveScan() }
|
||||
appScope.launch {
|
||||
val scanStartMs = System.currentTimeMillis()
|
||||
ssdp.requestActiveScan()
|
||||
// Give present devices time to answer the M-SEARCH (+ our follow-up
|
||||
// description fetch), then drop any route that didn't re-announce.
|
||||
// Pruning is tied to picker-open scans only -- no background timer,
|
||||
// so route rows never flicker while the user isn't looking.
|
||||
delay(ACTIVE_SCAN_PRUNE_DELAY_MS)
|
||||
pruneRoutesNotSeenSince(scanStartMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop routes whose last NOTIFY / M-SEARCH response predates [cutoffMs]
|
||||
* -- i.e. they didn't answer the active scan that just ran, so they're
|
||||
* gone from the LAN. Called only after [upgradeDiscovery]'s scan window.
|
||||
*/
|
||||
private fun pruneRoutesNotSeenSince(cutoffMs: Long) {
|
||||
val before = routesInternal.value
|
||||
val survivors = before.filter { (lastSeenMs[it.id] ?: 0L) >= cutoffMs }
|
||||
if (survivors.size == before.size) return
|
||||
val removed = before.filterNot { it in survivors }.map { it.id }
|
||||
removed.forEach { lastSeenMs.remove(it) }
|
||||
Timber.w("UPnP discovery: pruned %d stale route(s): %s", removed.size, removed)
|
||||
routesInternal.value = survivors
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,6 +147,7 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
}
|
||||
|
||||
private fun upsertRoute(route: UpnpRoute) {
|
||||
lastSeenMs[route.id] = System.currentTimeMillis()
|
||||
val current = routesInternal.value
|
||||
val idx = current.indexOfFirst { it.id == route.id }
|
||||
routesInternal.value = if (idx < 0) {
|
||||
@@ -240,6 +273,11 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
||||
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
||||
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
||||
|
||||
// Grace window after an M-SEARCH burst before pruning non-responders.
|
||||
// Covers the SSDP round-trip plus our per-device description fetch;
|
||||
// generous enough that a present-but-slow renderer isn't false-pruned.
|
||||
const val ACTIVE_SCAN_PRUNE_DELAY_MS = 4_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+68
@@ -28,6 +28,10 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -139,6 +143,15 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
private val regeneratedChannel = Channel<String>(Channel.BUFFERED)
|
||||
val regenerated: Flow<String> = regeneratedChannel.receiveAsFlow()
|
||||
|
||||
/**
|
||||
* #980: a system-playlist rebuild landed (SSE) while this screen is open —
|
||||
* its list is now stale and the uuid has rotated. Drives a "this mix was
|
||||
* refreshed → Refresh" snackbar rather than yanking the user; Refresh
|
||||
* re-resolves the variant via [reloadRebuilt].
|
||||
*/
|
||||
private val staleInternal = MutableStateFlow(false)
|
||||
val stale: StateFlow<Boolean> = staleInternal.asStateFlow()
|
||||
|
||||
val likedTrackIds: StateFlow<Set<String>> =
|
||||
likes.observeLikedTrackIds()
|
||||
.stateIn(
|
||||
@@ -163,6 +176,12 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
* ignored — the playlists-list screen handles those.
|
||||
*/
|
||||
private fun handlePlaylistEvent(event: LiveEvent) {
|
||||
// #980: a system rebuild carries no playlist_id (it rebuilds all of the
|
||||
// user's system mixes at once) — handle it before the per-id filter.
|
||||
if (event.kind == "playlist.system_rebuilt") {
|
||||
markStaleIfSystem()
|
||||
return
|
||||
}
|
||||
val eventPlaylistId = event.data["playlist_id"]?.jsonPrimitive?.contentOrNull
|
||||
if (eventPlaylistId != playlistId) return
|
||||
when (event.kind) {
|
||||
@@ -171,6 +190,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun markStaleIfSystem() {
|
||||
val playlist = (internal.value as? PlaylistDetailUiState.Success)
|
||||
?.detail?.playlist ?: return
|
||||
if (playlist.systemVariant?.takeIf { playlist.refreshable } != null) {
|
||||
staleInternal.value = true
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleLikeTrack(trackId: String) {
|
||||
val desired = trackId !in likedTrackIds.value
|
||||
viewModelScope.launch {
|
||||
@@ -220,6 +247,30 @@ class PlaylistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #980: user tapped Refresh on the stale-view snackbar. The rebuild
|
||||
* already ran server-side (uuid rotated) — re-resolve the variant to the
|
||||
* fresh playlist id and hand it to [regenerated] for navigate-replace.
|
||||
* Unlike [regenerate] this does NOT trigger another rebuild.
|
||||
*/
|
||||
fun reloadRebuilt() {
|
||||
val playlist = (internal.value as? PlaylistDetailUiState.Success)
|
||||
?.detail?.playlist ?: return
|
||||
val variant = playlist.systemVariant?.takeIf { playlist.refreshable } ?: return
|
||||
staleInternal.value = false
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.systemShuffle(variant).playlist.id }
|
||||
.onSuccess { newId ->
|
||||
if (newId.isNotEmpty()) regeneratedChannel.trySend(newId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** #980: user dismissed the stale-view snackbar without refreshing. */
|
||||
fun dismissStale() {
|
||||
staleInternal.value = false
|
||||
}
|
||||
|
||||
/** Play the available tracks starting at [startTrackId] (or first available). */
|
||||
fun play(tracks: List<PlaylistTrackRef>, startTrackId: String?) {
|
||||
val refs = tracks.toPlayableTrackRefs()
|
||||
@@ -261,8 +312,25 @@ fun PlaylistDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
// #980: a rebuild landed while this system-playlist screen is open. Offer a
|
||||
// non-intrusive "refreshed → Refresh" snackbar; Refresh re-resolves the
|
||||
// rotated uuid (via the regenerated navigate-replace flow).
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val stale by viewModel.stale.collectAsState()
|
||||
LaunchedEffect(stale) {
|
||||
if (stale) {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = "This mix was refreshed",
|
||||
actionLabel = "Refresh",
|
||||
duration = SnackbarDuration.Indefinite,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed) viewModel.reloadRebuilt()
|
||||
else viewModel.dismissStale()
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(currentTitle(state)) },
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
package com.fabledsword.minstrel.shared.widgets
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||
|
||||
/**
|
||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||
* is blank or unresolvable.
|
||||
* is blank/unresolvable, while the image is still loading, and when the
|
||||
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||
* which the "You might like" row hits often).
|
||||
*/
|
||||
@Composable
|
||||
fun ServerImage(
|
||||
@@ -22,12 +32,26 @@ fun ServerImage(
|
||||
val resolved = resolveServerUrl(url)
|
||||
if (resolved == null) {
|
||||
fallback()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
// Track Coil's load state so the fallback doubles as a placeholder
|
||||
// (loading) and an error state (404 / unreachable) — not just a
|
||||
// null-URL guard, which left present-but-failing URLs blank.
|
||||
var state by remember(resolved) {
|
||||
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
||||
}
|
||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = resolved,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = contentScale,
|
||||
onState = { state = it },
|
||||
)
|
||||
if (state is AsyncImagePainter.State.Loading ||
|
||||
state is AsyncImagePainter.State.Error
|
||||
) {
|
||||
fallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+18
-8
@@ -6,17 +6,27 @@ in-app update flow (#397).
|
||||
|
||||
## Production
|
||||
|
||||
CI populates this directory on tag releases:
|
||||
1. `flutter.yml` builds `app-release.apk` and attaches it to the
|
||||
Gitea release as `minstrel-<TAG>.apk`.
|
||||
2. `release.yml` waits for that asset to appear, downloads it into
|
||||
this directory as `minstrel.apk`, writes the tag string to
|
||||
`minstrel.apk.version`, and `docker buildx build` includes both
|
||||
via `COPY client/ /app/client/`.
|
||||
CI populates this directory in `release.yml` (no Flutter — the client is
|
||||
native Android, built by the `android-release` job):
|
||||
|
||||
- **Tag release (`v*`):** the `android-release` job builds + signs the
|
||||
APK, uploads it as a workflow artifact, and attaches it to the Gitea
|
||||
release as `minstrel-<TAG>.apk`. The `image-release` job
|
||||
(`needs: android-release`) downloads that artifact into this directory
|
||||
as `minstrel.apk` and writes the computed versionName to
|
||||
`minstrel.apk.version`.
|
||||
- **Main push (non-tag `:latest` / `:main`):** no APK is built, but the
|
||||
image still moves `:latest`, so `image-release` pulls the most-recent
|
||||
release's signed APK from the Gitea API and reconstructs its exact
|
||||
versionName — keeping the in-app update channel alive on `:latest`
|
||||
instead of stripping it until the next tag.
|
||||
|
||||
Either way, `docker buildx build` bakes both files into the image via
|
||||
`COPY client/ /app/client/`.
|
||||
|
||||
## Development
|
||||
|
||||
Empty directory works fine — the endpoints return 404 and the Flutter
|
||||
Empty directory works fine — the endpoints return 404 and the Android
|
||||
update banner stays hidden ("no update channel" graceful degradation).
|
||||
|
||||
To smoke-test the update flow locally, drop a real APK + a version
|
||||
|
||||
@@ -242,7 +242,12 @@ func run() error {
|
||||
// active user's daily build at 03:00 in their stored timezone.
|
||||
// Replaces the 24h-anchored cron loop (removed in the next commit
|
||||
// of this arc).
|
||||
playlistScheduler, err := playlists.NewScheduler(pool, logger.With("component", "playlist_scheduler"), cfg.Storage.DataDir)
|
||||
playlistScheduler, err := playlists.NewScheduler(
|
||||
pool,
|
||||
logger.With("component", "playlist_scheduler"),
|
||||
cfg.Storage.DataDir,
|
||||
bus,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init playlist scheduler: %w", err)
|
||||
}
|
||||
|
||||
+7
-2
@@ -26,8 +26,13 @@ library:
|
||||
# Filesystem roots to scan for music. Each path is walked recursively.
|
||||
# Env: MINSTREL_LIBRARY_SCAN_PATHS (colon-separated, PATH-style)
|
||||
scan_paths: []
|
||||
# Kick off a scan on server startup. Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
|
||||
scan_on_startup: false
|
||||
# Kick off a scan on server startup. On by default so a fresh install
|
||||
# populates its library on first boot with no manual step. Scans are
|
||||
# incremental (unchanged files are skipped by mtime), so the cost on
|
||||
# every later restart is just a directory walk, not a full re-index.
|
||||
# Set false if you'd rather scan only on a schedule / via the admin UI.
|
||||
# Env: MINSTREL_LIBRARY_SCAN_ON_STARTUP
|
||||
scan_on_startup: true
|
||||
|
||||
# Per-instance branding. Operators can rename their instance ("Family
|
||||
# Jukebox", "Office Music", etc.) and override the OG share-preview
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# README screenshots
|
||||
|
||||
Drop the files below into this directory with these **exact names** — the
|
||||
root `README.md` already references them, so no Markdown editing is needed.
|
||||
Export as PNG. Capture while logged in as admin and *after* the first
|
||||
library scan so nothing looks empty; keep the same browser width and theme
|
||||
across all shots for a consistent look.
|
||||
|
||||
| Filename | Route | What to capture |
|
||||
|---|---|---|
|
||||
| `home.png` | `/` | The home page with a populated library (system playlists / recently added) |
|
||||
| `register.png` | `/register` | The account-creation form on a fresh instance |
|
||||
| `library-scan.png` | `/admin` | The library scan running in stages (grab it mid-scan so stage tallies show) |
|
||||
| `integrations.png` | `/admin/integrations` | The ListenBrainz + Lidarr integration cards |
|
||||
| `android-download.png` | `/settings` | The "Install the Android app" download card |
|
||||
| `invite-users.png` | `/admin/users` | The invite-token generation / users list |
|
||||
|
||||
Tip: to control display width in the README, you can swap the Markdown
|
||||
`` for an HTML tag, e.g. `<img src="docs/screenshots/home.png" width="800" alt="...">`.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 170 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -74,6 +74,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
authed.Get("/me", h.handleGetMe)
|
||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
||||
authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
||||
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
||||
authed.Get("/me/history", h.handleGetMyHistory)
|
||||
|
||||
@@ -88,6 +88,21 @@ func (h *handlers) publishPlaylistEvent(kind string, ownerID, playlistID pgtype.
|
||||
})
|
||||
}
|
||||
|
||||
// publishSystemRebuilt notifies the owner's clients that their system
|
||||
// playlists (and You-might-like rows) were regenerated, so they invalidate
|
||||
// the home / system-playlist providers and a stale active queue can re-pull.
|
||||
// Mirrors the daily scheduler's event; fired here from the manual refresh.
|
||||
func (h *handlers) publishSystemRebuilt(userID pgtype.UUID) {
|
||||
if h.eventbus == nil {
|
||||
return
|
||||
}
|
||||
h.eventbus.Publish(eventbus.Event{
|
||||
Kind: "playlist.system_rebuilt",
|
||||
UserID: uuidToString(userID),
|
||||
Data: map[string]any{},
|
||||
})
|
||||
}
|
||||
|
||||
// publishRequestStatusChanged broadcasts a Lidarr request status flip to
|
||||
// the request's original requester so their /requests page reflects the
|
||||
// new state without manual refresh. Admin actors (approve / reject) still
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
const (
|
||||
recMetricsDefaultDays = 30
|
||||
recMetricsMaxDays = 365
|
||||
)
|
||||
|
||||
// recommendationMetric is one recommendation surface's outcomes.
|
||||
type recommendationMetric struct {
|
||||
Source string `json:"source"` // 'for_you' | 'discover' | mixes
|
||||
Plays int64 `json:"plays"` // plays launched from this surface
|
||||
Skips int64 `json:"skips"` // of those, marked skipped
|
||||
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||
}
|
||||
|
||||
type recommendationMetricsResp struct {
|
||||
WindowDays int `json:"window_days"`
|
||||
Sources []recommendationMetric `json:"sources"`
|
||||
}
|
||||
|
||||
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
|
||||
// caller over the last `days` (default 30, capped at 365), so the operator can
|
||||
// see which recommendation surfaces are landing and tune the taste weights.
|
||||
// Only plays tagged with a system-playlist source count; library/radio plays
|
||||
// (no source) are excluded.
|
||||
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
days, ok := parseMetricsDays(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := dbq.New(h.pool).RecommendationSourceMetricsForUser(r.Context(),
|
||||
dbq.RecommendationSourceMetricsForUserParams{UserID: caller.ID, Column2: float64(days)})
|
||||
if err != nil {
|
||||
h.logger.Error("api: recommendation metrics", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
out := recommendationMetricsResp{
|
||||
WindowDays: days,
|
||||
Sources: make([]recommendationMetric, 0, len(rows)),
|
||||
}
|
||||
for _, row := range rows {
|
||||
source := ""
|
||||
if row.Source != nil {
|
||||
source = *row.Source
|
||||
}
|
||||
var skipRate float64
|
||||
if row.Plays > 0 {
|
||||
skipRate = float64(row.Skips) / float64(row.Plays)
|
||||
}
|
||||
out.Sources = append(out.Sources, recommendationMetric{
|
||||
Source: source,
|
||||
Plays: row.Plays,
|
||||
Skips: row.Skips,
|
||||
SkipRate: skipRate,
|
||||
AvgCompletion: row.AvgCompletion,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
||||
// Writes a 400 and returns ok=false on a malformed value.
|
||||
func parseMetricsDays(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||
v := r.URL.Query().Get("days")
|
||||
if v == "" {
|
||||
return recMetricsDefaultDays, true
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid days"))
|
||||
return 0, false
|
||||
}
|
||||
if n > recMetricsMaxDays {
|
||||
n = recMetricsMaxDays
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func newMetricsRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||
return r
|
||||
}
|
||||
|
||||
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
||||
// skip flag. A nil source inserts NULL (library/radio play).
|
||||
func seedSourcedPlay(
|
||||
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
||||
source *string, completion float64, skipped bool,
|
||||
) {
|
||||
t.Helper()
|
||||
if _, err := h.pool.Exec(context.Background(),
|
||||
`INSERT INTO play_events
|
||||
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
|
||||
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
|
||||
userID, trackID, sessionID, source, completion, skipped); err != nil {
|
||||
t.Fatalf("seed sourced play: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationMetrics_NoSession401(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newMetricsRouter(h).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "metrics", "pw", false)
|
||||
artist := seedArtist(t, pool, "MetricArtist")
|
||||
album := seedAlbum(t, pool, artist.ID, "MetricAlbum", 2020)
|
||||
tk := seedTrack(t, pool, album.ID, artist.ID, "MetricTrack", 1, 200000)
|
||||
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||
|
||||
forYou := "for_you"
|
||||
discover := "discover"
|
||||
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
|
||||
// discover: 1 play.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
|
||||
// library play (NULL source) — must be excluded.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newMetricsRouter(h).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var resp recommendationMetricsResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.WindowDays != recMetricsDefaultDays {
|
||||
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
||||
}
|
||||
bySource := map[string]recommendationMetric{}
|
||||
for _, m := range resp.Sources {
|
||||
bySource[m.Source] = m
|
||||
}
|
||||
if _, present := bySource[""]; present {
|
||||
t.Error("NULL-source (library) plays should be excluded")
|
||||
}
|
||||
fy, ok := bySource["for_you"]
|
||||
if !ok {
|
||||
t.Fatal("for_you metrics missing")
|
||||
}
|
||||
if fy.Plays != 3 || fy.Skips != 1 {
|
||||
t.Errorf("for_you plays/skips = %d/%d, want 3/1", fy.Plays, fy.Skips)
|
||||
}
|
||||
if fy.SkipRate < 0.32 || fy.SkipRate > 0.34 {
|
||||
t.Errorf("for_you skip_rate = %.3f, want ~0.333", fy.SkipRate)
|
||||
}
|
||||
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
||||
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
||||
}
|
||||
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
|
||||
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@ func (h *handlers) handleSystemPlaylistRefresh(w http.ResponseWriter, r *http.Re
|
||||
writeErr(w, apierror.InternalMsg("build failed", err))
|
||||
return
|
||||
}
|
||||
// #968: announce the rebuild so the user's other clients refresh.
|
||||
h.publishSystemRebuilt(user.ID)
|
||||
q := dbq.New(h.pool)
|
||||
v := kind
|
||||
pl, err := q.GetSystemPlaylistByVariantForUser(r.Context(),
|
||||
|
||||
@@ -135,6 +135,11 @@ func Default() Config {
|
||||
RadioSizeMax: 200,
|
||||
},
|
||||
Library: LibraryConfig{
|
||||
// On by default so a fresh install populates its library on
|
||||
// first boot without the operator hunting for a "scan now"
|
||||
// button. Scans are incremental (unchanged files skipped by
|
||||
// mtime), so every later restart is just a cheap directory walk.
|
||||
ScanOnStartup: true,
|
||||
CoverArtFromMBCAA: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -97,7 +97,8 @@ func TestEnvOverrides(t *testing.T) {
|
||||
func TestLibraryEnvOverrides(t *testing.T) {
|
||||
stubStreamSecret(t)
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_PATHS", "/music:/other::/third")
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "true")
|
||||
// Default is true; set false so the override path is actually exercised.
|
||||
t.Setenv("MINSTREL_LIBRARY_SCAN_ON_STARTUP", "false")
|
||||
|
||||
cfg, err := Load("")
|
||||
if err != nil {
|
||||
@@ -112,8 +113,8 @@ func TestLibraryEnvOverrides(t *testing.T) {
|
||||
t.Errorf("scan_paths[%d] = %q, want %q", i, cfg.Library.ScanPaths[i], p)
|
||||
}
|
||||
}
|
||||
if !cfg.Library.ScanOnStartup {
|
||||
t.Error("scan_on_startup = false, want true")
|
||||
if cfg.Library.ScanOnStartup {
|
||||
t.Error("scan_on_startup = true, want false (env override of the true default)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -737,6 +737,17 @@ likes_overlap AS (
|
||||
ORDER BY random()
|
||||
LIMIT $8
|
||||
),
|
||||
taste_overlap AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM taste_profile_artists tpa
|
||||
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||
WHERE tpa.user_id = $1
|
||||
AND tpa.weight > 0
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
AND t.id <> $2
|
||||
ORDER BY tpa.weight DESC, t.id
|
||||
LIMIT $10
|
||||
),
|
||||
random_fill AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -747,6 +758,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM similar_artists
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -763,6 +775,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
@@ -790,6 +803,7 @@ type LoadRadioCandidatesV2Params struct {
|
||||
Limit_3 int32
|
||||
Limit_4 int32
|
||||
Limit_5 int32
|
||||
Limit_6 int32
|
||||
}
|
||||
|
||||
type LoadRadioCandidatesV2Row struct {
|
||||
@@ -801,11 +815,15 @@ type LoadRadioCandidatesV2Row struct {
|
||||
SimilarityScore interface{}
|
||||
}
|
||||
|
||||
// M4c: similarity-driven candidate pool. 5-way UNION:
|
||||
// M4c: similarity-driven candidate pool. 6-way UNION:
|
||||
//
|
||||
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||||
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
|
||||
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||||
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||||
// enter the pool even when the similarity/random arms miss them; scored
|
||||
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||
//
|
||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||
@@ -819,6 +837,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
arg.Limit_3,
|
||||
arg.Limit_4,
|
||||
arg.Limit_5,
|
||||
arg.Limit_6,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: recommendation_metrics.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
|
||||
|
||||
SELECT
|
||||
pe.source,
|
||||
count(*)::bigint AS plays,
|
||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||
COALESCE(
|
||||
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
||||
0)::float8 AS avg_completion
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.source IS NOT NULL
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
GROUP BY pe.source
|
||||
ORDER BY plays DESC
|
||||
`
|
||||
|
||||
type RecommendationSourceMetricsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Column2 float64
|
||||
}
|
||||
|
||||
type RecommendationSourceMetricsForUserRow struct {
|
||||
Source *string
|
||||
Plays int64
|
||||
Skips int64
|
||||
AvgCompletion float64
|
||||
}
|
||||
|
||||
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||
// operator can see whether each recommendation surface is landing and tune the
|
||||
// taste weights. Source is stamped on play_events when a play is launched from
|
||||
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
||||
// NULL for library / radio / user-playlist plays, which are excluded here.
|
||||
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||
// mean completion ratio over plays that recorded one (0 when none did).
|
||||
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []RecommendationSourceMetricsForUserRow
|
||||
for rows.Next() {
|
||||
var i RecommendationSourceMetricsForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.Source,
|
||||
&i.Plays,
|
||||
&i.Skips,
|
||||
&i.AvgCompletion,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -91,6 +91,70 @@ func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMig
|
||||
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
|
||||
@@ -149,6 +213,90 @@ func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYou
|
||||
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,
|
||||
|
||||
@@ -33,10 +33,14 @@ WHERE t.id <> $2
|
||||
);
|
||||
|
||||
-- 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,
|
||||
-- $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.
|
||||
|
||||
WITH
|
||||
@@ -109,6 +113,17 @@ likes_overlap AS (
|
||||
ORDER BY random()
|
||||
LIMIT $8
|
||||
),
|
||||
taste_overlap AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM taste_profile_artists tpa
|
||||
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||
WHERE tpa.user_id = $1
|
||||
AND tpa.weight > 0
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
AND t.id <> $2
|
||||
ORDER BY tpa.weight DESC, t.id
|
||||
LIMIT $10
|
||||
),
|
||||
random_fill AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -119,6 +134,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM similar_artists
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -135,6 +151,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
|
||||
@@ -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;
|
||||
@@ -64,3 +64,62 @@ LEFT JOIN LATERAL (
|
||||
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;
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||
)
|
||||
|
||||
@@ -47,14 +48,21 @@ type Scheduler struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
dataDir string
|
||||
bus *eventbus.Bus // #968: announce rebuilds so clients self-refresh
|
||||
|
||||
mu sync.Mutex
|
||||
jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
|
||||
}
|
||||
|
||||
// NewScheduler builds an idle scheduler. Caller must invoke Start
|
||||
// before any builds fire.
|
||||
func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Scheduler, error) {
|
||||
// before any builds fire. bus may be nil (tests); rebuild events are
|
||||
// then skipped.
|
||||
func NewScheduler(
|
||||
pool *pgxpool.Pool,
|
||||
logger *slog.Logger,
|
||||
dataDir string,
|
||||
bus *eventbus.Bus,
|
||||
) (*Scheduler, error) {
|
||||
g, err := gocron.NewScheduler()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("init gocron: %w", err)
|
||||
@@ -64,6 +72,7 @@ func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger, dataDir string) (*Sch
|
||||
pool: pool,
|
||||
logger: logger,
|
||||
dataDir: dataDir,
|
||||
bus: bus,
|
||||
jobs: map[pgtype.UUID]uuid.UUID{},
|
||||
}, nil
|
||||
}
|
||||
@@ -234,7 +243,26 @@ func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, no
|
||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil {
|
||||
s.logger.Warn("scheduler: build failed",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return
|
||||
}
|
||||
// #968: tell the user's connected clients their home content was
|
||||
// regenerated, so a tab/app left open across the rebuild refreshes its
|
||||
// system-playlist + You-might-like views (and a stale active queue can
|
||||
// re-pull) instead of serving yesterday's snapshot until a manual reload.
|
||||
s.publishRebuilt(userID)
|
||||
}
|
||||
|
||||
// publishRebuilt broadcasts a user-scoped "system playlists rebuilt" event.
|
||||
// No-op when the bus is nil (test construction).
|
||||
func (s *Scheduler) publishRebuilt(userID pgtype.UUID) {
|
||||
if s.bus == nil {
|
||||
return
|
||||
}
|
||||
s.bus.Publish(eventbus.Event{
|
||||
Kind: "playlist.system_rebuilt",
|
||||
UserID: uuidStringPL(userID),
|
||||
Data: map[string]any{},
|
||||
})
|
||||
}
|
||||
|
||||
// Stop drains gocron and stops the scheduler loop.
|
||||
|
||||
@@ -329,6 +329,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
||||
TagOverlap: 60,
|
||||
LikesOverlap: 40,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,9 +89,15 @@ func buildYouMightLike(
|
||||
}
|
||||
|
||||
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}, systemForYouSourceLimits(),
|
||||
[]pgtype.UUID{seed}, ymlLimits,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn("you-might-like: candidate load failed; skipping",
|
||||
|
||||
@@ -72,6 +72,10 @@ type CandidateSourceLimits struct {
|
||||
TagOverlap int
|
||||
LikesOverlap 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.
|
||||
@@ -82,6 +86,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
TagOverlap: 20,
|
||||
LikesOverlap: 20,
|
||||
RandomFill: 30,
|
||||
TasteOverlap: 20,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +118,7 @@ func LoadCandidatesFromSimilarity(
|
||||
Limit_3: int32(limits.TagOverlap),
|
||||
Limit_4: int32(limits.LikesOverlap),
|
||||
Limit_5: int32(limits.RandomFill),
|
||||
Limit_6: int32(limits.TasteOverlap),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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) {
|
||||
f := newFixture(t, 1) // just the seed
|
||||
seed := f.tracks[0]
|
||||
|
||||
@@ -199,11 +199,12 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
|
||||
const youMightLikeFallbackFetch = 100
|
||||
|
||||
// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
|
||||
// user's liked artists (daily-stable sample), excluding 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. The liked
|
||||
// pool dwarfs the similarity roll-up, so the same exclusions still fill it.
|
||||
// Best-effort: a query error leaves the section as-is.
|
||||
// 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 {
|
||||
@@ -218,7 +219,7 @@ func topUpYouMightLikeArtists(
|
||||
for _, r := range result {
|
||||
excluded[r.Artist.ID] = struct{}{}
|
||||
}
|
||||
fb, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{
|
||||
fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{
|
||||
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -249,7 +250,7 @@ func topUpYouMightLikeAlbums(
|
||||
for _, r := range result {
|
||||
excluded[r.Album.ID] = struct{}{}
|
||||
}
|
||||
fb, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{
|
||||
fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{
|
||||
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -48,6 +48,40 @@ func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "home-newuser")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,18 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { AlbumRef, AlbumDetail } from '$lib/api/types';
|
||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||
import { api } from '$lib/api/client';
|
||||
import { enqueueTracks, playQueue } from '$lib/player/store.svelte';
|
||||
import { Play } from 'lucide-svelte';
|
||||
import AlbumMenu from './AlbumMenu.svelte';
|
||||
import CardActionCluster from './CardActionCluster.svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
|
||||
let { album }: { album: AlbumRef } = $props();
|
||||
|
||||
function onImgError(e: Event) {
|
||||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
||||
}
|
||||
|
||||
async function onAddClick(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -38,12 +34,9 @@
|
||||
shadow-sm transition-all duration-150
|
||||
group-hover:shadow-lg group-hover:ring-1 group-hover:ring-accent/40"
|
||||
>
|
||||
<img
|
||||
<Cover
|
||||
src={album.cover_url}
|
||||
alt=""
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
onerror={onImgError}
|
||||
class="transition-transform group-hover:scale-[1.03]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
||||
import { api } from '$lib/api/client';
|
||||
import { playQueue, enqueueTracks } from '$lib/player/store.svelte';
|
||||
import { Disc3, Play } from 'lucide-svelte';
|
||||
import { Play } from 'lucide-svelte';
|
||||
import ArtistMenu from './ArtistMenu.svelte';
|
||||
import CardActionCluster from './CardActionCluster.svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
import { listArtistTracks } from '$lib/api/artists';
|
||||
|
||||
let { artist }: { artist: ArtistRef } = $props();
|
||||
@@ -48,18 +49,12 @@
|
||||
class="block rounded focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<div class="art-wrap relative mx-auto aspect-square w-full overflow-hidden rounded-full bg-surface-hover">
|
||||
{#if artist.cover_url}
|
||||
<img
|
||||
src={artist.cover_url}
|
||||
alt=""
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
|
||||
</div>
|
||||
{/if}
|
||||
<Cover
|
||||
src={artist.cover_url}
|
||||
shape="round"
|
||||
fallback="artist"
|
||||
class="transition-transform group-hover:scale-[1.03]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Play ${artist.name}`}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Plus } from 'lucide-svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import { playQueue, enqueueTrack } from '$lib/player/store.svelte';
|
||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||
import { coverUrl } from '$lib/media/covers';
|
||||
import TrackMenu from './TrackMenu.svelte';
|
||||
import CardActionCluster from './CardActionCluster.svelte';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import Cover from './Cover.svelte';
|
||||
|
||||
// Horizontal compact track row — cover thumb on the left, title +
|
||||
// artist on the right. Mirrors Android's CompactTrackTile so the
|
||||
@@ -22,10 +24,6 @@
|
||||
|
||||
const cover = $derived(coverUrl(track.album_id));
|
||||
|
||||
function onImgError(e: Event) {
|
||||
(e.currentTarget as HTMLImageElement).src = FALLBACK_COVER;
|
||||
}
|
||||
|
||||
function onClick() {
|
||||
playQueue(sectionTracks, index);
|
||||
}
|
||||
@@ -42,31 +40,40 @@
|
||||
type="button"
|
||||
aria-label={`Play ${track.title}`}
|
||||
onclick={onClick}
|
||||
class="flex w-full items-center gap-2 rounded-md p-1 pr-12 text-left
|
||||
class="flex w-full items-center gap-2 rounded-md p-1 pr-24 text-left
|
||||
hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<div class="h-12 w-12 flex-shrink-0 overflow-hidden rounded bg-surface-hover">
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onerror={onImgError}
|
||||
/>
|
||||
<Cover src={cover} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-text-primary">{track.title}</div>
|
||||
<div class="truncate text-xs text-text-secondary">{track.artist_name}</div>
|
||||
</div>
|
||||
</button>
|
||||
<CardActionCluster
|
||||
likeEntityType="track"
|
||||
likeEntityId={track.id}
|
||||
onAdd={onAddToQueue}
|
||||
addLabel={`Add ${track.title} to queue`}
|
||||
<!-- Single inline cluster, vertically centred on the right. The shared
|
||||
CardActionCluster splits Like+Add (top) and the menu (bottom) into
|
||||
opposite corners — correct for the tall square Album/Artist cards but
|
||||
it makes the two groups collide on this short one-line row. A compact
|
||||
row keeps all three controls in one horizontal group instead. -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute right-2 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5
|
||||
opacity-0 transition-opacity duration-150
|
||||
group-hover:opacity-100 focus-within:opacity-100"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{#snippet menu()}
|
||||
<TrackMenu {track} />
|
||||
{/snippet}
|
||||
</CardActionCluster>
|
||||
<LikeButton entityType="track" entityId={track.id} size="sm" />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Add ${track.title} to queue`}
|
||||
onclick={onAddToQueue}
|
||||
class="rounded-full bg-surface p-1 text-text-secondary hover:text-text-primary
|
||||
focus-visible:ring-2 focus-visible:ring-accent"
|
||||
>
|
||||
<Plus size={16} strokeWidth={1} />
|
||||
</button>
|
||||
<TrackMenu {track} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||
|
||||
vi.mock('$lib/api/admin/tracks', () => ({
|
||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
||||
|
||||
vi.mock('$lib/auth/store.svelte', () => ({
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { FALLBACK_COVER } from '$lib/media/covers';
|
||||
import { Disc3 } from 'lucide-svelte';
|
||||
|
||||
// Shared cover-artwork renderer. The web counterpart of Android's
|
||||
// CoverTile/ServerImage: one place that owns the loading placeholder and
|
||||
// the error fallback so no card sits blank while art loads or 404s (the
|
||||
// "You might like" row surfaces unplayed items whose art is often not yet
|
||||
// backfilled). Replaces the three hand-rolled <img> tags that previously
|
||||
// disagreed on fallback handling.
|
||||
let {
|
||||
src,
|
||||
alt = '',
|
||||
shape = 'square',
|
||||
fallback = 'image',
|
||||
class: klass = ''
|
||||
}: {
|
||||
src: string | undefined;
|
||||
alt?: string;
|
||||
// 'round' clips to a circle (artist avatars); 'square' leaves clipping
|
||||
// to the parent so existing rounded-md / rounded corners still apply.
|
||||
shape?: 'square' | 'round';
|
||||
// 'artist' degrades to a Disc3 icon; 'image' to the static placeholder
|
||||
// cover. A null/empty src is treated the same as a failed load.
|
||||
fallback?: 'image' | 'artist';
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
// Per-image lifecycle. Reset when src changes so a reused card (virtualized
|
||||
// list, client-side nav) never keeps a stale 'loaded'/'failed'.
|
||||
let loaded = $state(false);
|
||||
let failed = $state(false);
|
||||
$effect(() => {
|
||||
void src;
|
||||
loaded = false;
|
||||
failed = false;
|
||||
});
|
||||
|
||||
const useIcon = $derived(fallback === 'artist' && (failed || !src));
|
||||
// On failure for the image fallback, swap to the static placeholder asset.
|
||||
// src doesn't change on the second error, so there's no reload loop.
|
||||
const imgSrc = $derived(failed ? FALLBACK_COVER : src);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden bg-surface-hover {shape === 'round'
|
||||
? 'rounded-full'
|
||||
: ''}"
|
||||
>
|
||||
{#if useIcon}
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<Disc3 strokeWidth={1} class="h-4/5 w-4/5 text-text-muted" />
|
||||
</div>
|
||||
{:else if imgSrc}
|
||||
<img
|
||||
src={imgSrc}
|
||||
{alt}
|
||||
class="h-full w-full object-cover transition-opacity duration-200 {loaded
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'} {klass}"
|
||||
loading="lazy"
|
||||
onload={() => (loaded = true)}
|
||||
onerror={() => (failed = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -272,8 +272,12 @@
|
||||
data-testid="player-bar-desktop"
|
||||
class="hidden md:flex md:flex-row md:min-h-[108px] md:items-center md:gap-4 border-t border-border bg-surface md:px-4 md:py-1.5"
|
||||
>
|
||||
<!-- Left: cover + title + artist + like + menu -->
|
||||
<div class="flex w-72 min-w-0 items-center gap-3">
|
||||
<!-- Left: cover + title + artist + like + menu.
|
||||
basis-72 grow max-w-md: holds its 288px floor at the md breakpoint
|
||||
(where the seek column is the flex-1 that absorbs the remainder), but
|
||||
on wider screens it grows up to 448px instead of leaving the title
|
||||
truncated while the seek bar hogs the slack. -->
|
||||
<div class="flex basis-72 grow max-w-md min-w-0 items-center gap-3">
|
||||
<a
|
||||
href="/now-playing"
|
||||
aria-label="Open now playing"
|
||||
@@ -318,7 +322,10 @@
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-1 flex-col items-stretch gap-2">
|
||||
<!-- max-w-xl + mx-auto caps the seek bar (and the transport row) at
|
||||
576px and centres it, so on wide screens the extra width flows to
|
||||
the title column on the left rather than stretching the scrubber. -->
|
||||
<div class="flex flex-1 flex-col items-stretch gap-2 mx-auto w-full max-w-xl">
|
||||
<!-- Transport row -->
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { getPlaylist, systemShuffle, refreshSystem } from '$lib/api/playlists';
|
||||
import { playlistTrackToRef } from '$lib/playlists/playlistTrackToRef';
|
||||
import { systemPlaylistRefetch } from '$lib/playlists/systemRefetch';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { playQueue } from '$lib/player/store.svelte';
|
||||
@@ -80,7 +81,14 @@
|
||||
const detail = await systemShuffle(variant);
|
||||
const refs = toTrackRefs(detail.tracks);
|
||||
if (refs.length > 0) {
|
||||
playQueue(refs, 0, { source: variant });
|
||||
playQueue(refs, 0, {
|
||||
source: variant,
|
||||
refetch: systemPlaylistRefetch({
|
||||
variant,
|
||||
playlistId: playlist.id,
|
||||
perArtist: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const detail = await getPlaylist(playlist.id);
|
||||
@@ -93,7 +101,14 @@
|
||||
// so source attribution stays absent — the PlaylistCard
|
||||
// test pins this contract.
|
||||
if (variant != null) {
|
||||
playQueue(refs, 0, { source: variant });
|
||||
playQueue(refs, 0, {
|
||||
source: variant,
|
||||
refetch: systemPlaylistRefetch({
|
||||
variant,
|
||||
playlistId: playlist.id,
|
||||
perArtist: true,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
playQueue(refs, 0);
|
||||
}
|
||||
|
||||
@@ -21,10 +21,6 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||
|
||||
vi.mock('$lib/api/admin/tracks', () => ({
|
||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't-1' })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playNext: vi.fn(),
|
||||
enqueueTrack: vi.fn(),
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Trash2 } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import { removeTrack } from '$lib/api/admin/tracks';
|
||||
import { errMessage } from '$lib/api/errors';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
let {
|
||||
track,
|
||||
onClose
|
||||
}: {
|
||||
track: TrackRef;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
let unmonitor = $state(false);
|
||||
let busy = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function confirm() {
|
||||
busy = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await removeTrack(track.id, { unmonitor });
|
||||
// Invalidate caches for any vanished entity. The album/artist that
|
||||
// contained the track is always touched; if the parent rows were
|
||||
// tidied up by the server cascade, evict those too. Home payload
|
||||
// surfaces albums/artists on the landing page so it always
|
||||
// refreshes.
|
||||
await queryClient.invalidateQueries({ queryKey: qk.album(track.album_id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.artist(track.artist_id) });
|
||||
await queryClient.invalidateQueries({ queryKey: qk.home() });
|
||||
if (result.deleted_album_id) {
|
||||
await queryClient.invalidateQueries({ queryKey: qk.album(result.deleted_album_id) });
|
||||
}
|
||||
if (result.deleted_artist_id) {
|
||||
await queryClient.invalidateQueries({ queryKey: qk.artist(result.deleted_artist_id) });
|
||||
}
|
||||
// Note: result.lidarr_unmonitor_failed is true when unmonitor was
|
||||
// requested AND Lidarr couldn't be reached. The destructive part
|
||||
// already succeeded; we still close the popover. A future toast
|
||||
// surface (if any) can pick up the flag.
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
error = errMessage(e);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
aria-modal="false"
|
||||
aria-labelledby="remove-track-popover-title"
|
||||
class="absolute right-0 z-30 mt-1 w-72 rounded-lg border border-border bg-surface p-3 shadow-xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') onClose(); }}
|
||||
>
|
||||
<h4 id="remove-track-popover-title" class="text-sm font-medium text-text-primary">
|
||||
Remove "{track.title}" from your library?
|
||||
</h4>
|
||||
<p class="mt-1 text-xs text-text-muted">
|
||||
This deletes the file from disk.
|
||||
</p>
|
||||
|
||||
<label class="mt-3 flex items-start gap-2 text-sm text-text-primary">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={unmonitor}
|
||||
class="mt-0.5 rounded border-border"
|
||||
/>
|
||||
<span>Also stop Lidarr from finding a replacement</span>
|
||||
</label>
|
||||
|
||||
{#if error}
|
||||
<p class="mt-2 text-xs text-action-destructive">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md border border-border px-2.5 py-1 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
||||
onclick={onClose}
|
||||
disabled={busy}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={confirm}
|
||||
disabled={busy}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-border bg-surface px-2.5 py-1 text-sm text-action-destructive hover:bg-surface-hover disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={14} strokeWidth={1} />
|
||||
{busy ? 'Removing…' : 'Remove'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,97 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
import { makeTrack } from '$test-utils/fixtures/track';
|
||||
|
||||
const invalidateMock = vi.fn();
|
||||
vi.mock('@tanstack/svelte-query', async (orig) => {
|
||||
const actual = (await orig()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
useQueryClient: () => ({ invalidateQueries: invalidateMock })
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/admin/tracks', () => ({
|
||||
removeTrack: vi.fn()
|
||||
}));
|
||||
|
||||
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
|
||||
import { removeTrack } from '$lib/api/admin/tracks';
|
||||
|
||||
const track = makeTrack({ title: 'Roygbiv' });
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('RemoveTrackPopover', () => {
|
||||
test('renders the title and subtext', () => {
|
||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
||||
expect(screen.getByText(/remove "roygbiv" from your library/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/this deletes the file from disk/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the Lidarr unmonitor checkbox', () => {
|
||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
||||
const cb = screen.getByRole('checkbox', { name: /also stop lidarr/i });
|
||||
expect(cb).toBeInTheDocument();
|
||||
expect((cb as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
test('Cancel button calls onClose without firing removeTrack', async () => {
|
||||
const onClose = vi.fn();
|
||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(removeTrack).not.toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Remove without checkbox calls removeTrack with unmonitor=false', async () => {
|
||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
|
||||
const onClose = vi.fn();
|
||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
||||
// confirm() chains 3-5 awaited invalidateQueries calls before onClose,
|
||||
// each a separate microtask. waitFor polls until the side effects land.
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: false });
|
||||
});
|
||||
|
||||
test('Remove with checkbox checked calls removeTrack with unmonitor=true', async () => {
|
||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({ deleted_track_id: 't1' });
|
||||
const onClose = vi.fn();
|
||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
||||
await fireEvent.click(screen.getByRole('checkbox', { name: /also stop lidarr/i }));
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(removeTrack).toHaveBeenCalledWith('t1', { unmonitor: true });
|
||||
});
|
||||
|
||||
test('failed remove surfaces error via copyForCode and does not close', async () => {
|
||||
(removeTrack as ReturnType<typeof vi.fn>).mockRejectedValue({ code: 'not_found' });
|
||||
const onClose = vi.fn();
|
||||
render(RemoveTrackPopover, { props: { track, onClose } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
||||
await waitFor(() => expect(removeTrack).toHaveBeenCalled());
|
||||
// The catch path runs synchronously after the rejected await; one more
|
||||
// microtask boundary is enough.
|
||||
await Promise.resolve();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('success invalidates album, artist and home queries', async () => {
|
||||
(removeTrack as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
deleted_track_id: 't1',
|
||||
deleted_album_id: 'a1',
|
||||
deleted_artist_id: 'ar1'
|
||||
});
|
||||
render(RemoveTrackPopover, { props: { track, onClose: vi.fn() } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /^remove$/i }));
|
||||
// Wait for all three baseline invalidations (album, artist, home) plus
|
||||
// the two cascade ones (deleted_album_id, deleted_artist_id) to land.
|
||||
await waitFor(() => expect(invalidateMock.mock.calls.length).toBeGreaterThanOrEqual(5));
|
||||
const keys = invalidateMock.mock.calls.map((c) => c[0].queryKey);
|
||||
const flat = keys.map((k) => Array.isArray(k) ? k.join('|') : String(k));
|
||||
expect(flat.some((k) => k.startsWith('album|a1'))).toBe(true);
|
||||
expect(flat.some((k) => k.startsWith('artist|ar1'))).toBe(true);
|
||||
expect(flat.some((k) => k === 'home')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,30 +1,27 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
MoreVertical,
|
||||
ListPlus,
|
||||
Plus,
|
||||
ListVideo,
|
||||
ListMusic,
|
||||
Heart,
|
||||
HeartOff,
|
||||
ListMusic,
|
||||
Album,
|
||||
ListPlus,
|
||||
Radio,
|
||||
Disc3,
|
||||
Flag,
|
||||
User,
|
||||
EyeOff,
|
||||
Eye,
|
||||
Trash2
|
||||
Eye
|
||||
} from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import FlagPopover from './FlagPopover.svelte';
|
||||
import RemoveTrackPopover from './RemoveTrackPopover.svelte';
|
||||
import AddToPlaylistMenu from './AddToPlaylistMenu.svelte';
|
||||
import TrackMenuItem from './TrackMenuItem.svelte';
|
||||
import TrackMenuDivider from './TrackMenuDivider.svelte';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { createLikedIdsQuery, likeEntity, unlikeEntity } from '$lib/api/likes';
|
||||
import { createMyQuarantineQuery, unflagTrack } from '$lib/api/quarantine';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
|
||||
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
|
||||
let {
|
||||
@@ -44,7 +41,6 @@
|
||||
|
||||
let menuOpen = $state(false);
|
||||
let flagOpen = $state(false);
|
||||
let removeOpen = $state(false);
|
||||
let addOpen = $state(false);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
@@ -63,8 +59,6 @@
|
||||
return rows.some((r) => r.track_id === track.id);
|
||||
});
|
||||
|
||||
const isAdmin = $derived(user.value?.is_admin === true);
|
||||
|
||||
function toggleMenu(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
menuOpen = !menuOpen;
|
||||
@@ -73,7 +67,6 @@
|
||||
function closeAll() {
|
||||
menuOpen = false;
|
||||
flagOpen = false;
|
||||
removeOpen = false;
|
||||
addOpen = false;
|
||||
}
|
||||
|
||||
@@ -87,6 +80,11 @@
|
||||
closeAll();
|
||||
}
|
||||
|
||||
function onStartRadio() {
|
||||
playRadio(track.id);
|
||||
closeAll();
|
||||
}
|
||||
|
||||
async function onToggleLike() {
|
||||
closeAll();
|
||||
if (liked) {
|
||||
@@ -119,18 +117,8 @@
|
||||
goto(`/artists/${track.artist_id}`);
|
||||
}
|
||||
|
||||
function onFlag() {
|
||||
menuOpen = false;
|
||||
flagOpen = true;
|
||||
}
|
||||
|
||||
function onRemoveOpen() {
|
||||
menuOpen = false;
|
||||
removeOpen = true;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (!menuOpen && !flagOpen && !removeOpen && !addOpen) return;
|
||||
if (!menuOpen && !flagOpen && !addOpen) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAll();
|
||||
@@ -161,9 +149,16 @@
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { e.stopPropagation(); if (e.key === 'Escape') menuOpen = false; }}
|
||||
>
|
||||
<!-- Item set + order mirror Android's canonical TrackActionsSheet so
|
||||
the kebab reads the same on both clients: queue group → like /
|
||||
add-to-playlist / start-radio group → navigation group → hide.
|
||||
"Start radio" is shown even under hideQueueActions (reseeding a
|
||||
station from the current track is meaningful where play-next /
|
||||
add-to-queue are not). No single-click destructive action lives
|
||||
here — track removal is intentionally kept out of the kebab. -->
|
||||
{#if !hideQueueActions}
|
||||
<TrackMenuItem icon={ListPlus} label="Play next" onclick={onPlayNext} />
|
||||
<TrackMenuItem icon={Plus} label="Add to queue" onclick={onAddToQueue} />
|
||||
<TrackMenuItem icon={ListVideo} label="Play next" onclick={onPlayNext} />
|
||||
<TrackMenuItem icon={ListMusic} label="Add to queue" onclick={onAddToQueue} />
|
||||
|
||||
<TrackMenuDivider />
|
||||
{/if}
|
||||
@@ -174,32 +169,24 @@
|
||||
onclick={onToggleLike}
|
||||
/>
|
||||
<TrackMenuItem
|
||||
icon={ListMusic}
|
||||
icon={ListPlus}
|
||||
label="Add to playlist…"
|
||||
onclick={() => { addOpen = true; menuOpen = false; }}
|
||||
/>
|
||||
<TrackMenuItem icon={Radio} label="Start radio" onclick={onStartRadio} />
|
||||
|
||||
<TrackMenuDivider />
|
||||
|
||||
<TrackMenuItem icon={Album} label="Go to album" onclick={onGoToAlbum} />
|
||||
<TrackMenuItem icon={Disc3} label="Go to artist" onclick={onGoToArtist} />
|
||||
<TrackMenuItem icon={Disc3} label="Go to album" onclick={onGoToAlbum} />
|
||||
<TrackMenuItem icon={User} label="Go to artist" onclick={onGoToArtist} />
|
||||
|
||||
<TrackMenuDivider />
|
||||
|
||||
<TrackMenuItem icon={Flag} label="Flag this track…" onclick={onFlag} />
|
||||
<TrackMenuItem
|
||||
icon={hidden ? Eye : EyeOff}
|
||||
label={hidden ? 'Unhide' : 'Hide'}
|
||||
onclick={onToggleHide}
|
||||
/>
|
||||
{#if isAdmin}
|
||||
<TrackMenuItem
|
||||
icon={Trash2}
|
||||
label="Remove from library"
|
||||
onclick={onRemoveOpen}
|
||||
danger
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -207,10 +194,6 @@
|
||||
<FlagPopover {track} onClose={closeAll} />
|
||||
{/if}
|
||||
|
||||
{#if removeOpen}
|
||||
<RemoveTrackPopover {track} onClose={closeAll} />
|
||||
{/if}
|
||||
|
||||
{#if addOpen}
|
||||
<AddToPlaylistMenu tracks={[track]} onClose={() => (addOpen = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -25,19 +25,16 @@ vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||
|
||||
vi.mock('$lib/api/admin/tracks', () => ({
|
||||
removeTrack: vi.fn().mockResolvedValue({ deleted_track_id: 't1' })
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/playlists', () => emptyPlaylistsMock());
|
||||
|
||||
vi.mock('$lib/player/store.svelte', () => ({
|
||||
playNext: vi.fn(),
|
||||
enqueueTrack: vi.fn()
|
||||
enqueueTrack: vi.fn(),
|
||||
playRadio: vi.fn()
|
||||
}));
|
||||
|
||||
import TrackMenu from './TrackMenu.svelte';
|
||||
import { playNext, enqueueTrack } from '$lib/player/store.svelte';
|
||||
import { playNext, enqueueTrack, playRadio } from '$lib/player/store.svelte';
|
||||
|
||||
const track = makeTrack({ title: 'Roygbiv' });
|
||||
|
||||
@@ -55,7 +52,7 @@ describe('TrackMenu', () => {
|
||||
expect(kebab.getAttribute('aria-expanded')).toBe('true');
|
||||
});
|
||||
|
||||
test('opening menu shows all 9 entries for an admin user', async () => {
|
||||
test('opening menu shows all 8 entries', async () => {
|
||||
render(TrackMenu, { props: { track } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||
|
||||
@@ -63,19 +60,12 @@ describe('TrackMenu', () => {
|
||||
expect(screen.getByRole('menuitem', { name: /add to queue/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /^like$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /add to playlist/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /start radio/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /go to album/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /go to artist/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /flag this track/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /^hide$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: /remove from library/i })).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(9);
|
||||
});
|
||||
|
||||
test('"Remove from library" hidden for non-admin', async () => {
|
||||
userState.current = { id: 'u2', username: 'normal', is_admin: false };
|
||||
render(TrackMenu, { props: { track } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||
// No "Remove from library" — destructive removal is intentionally absent.
|
||||
expect(screen.queryByRole('menuitem', { name: /remove from library/i })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('menuitem')).toHaveLength(8);
|
||||
});
|
||||
@@ -113,10 +103,17 @@ describe('TrackMenu', () => {
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking "Flag this track…" opens FlagPopover and closes menu', async () => {
|
||||
test('"Start radio" dispatches playRadio(track.id)', async () => {
|
||||
render(TrackMenu, { props: { track } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: /flag this track/i }));
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: /start radio/i }));
|
||||
expect(playRadio).toHaveBeenCalledWith(track.id);
|
||||
});
|
||||
|
||||
test('clicking "Hide" opens the flag/quarantine popover and closes menu', async () => {
|
||||
render(TrackMenu, { props: { track } });
|
||||
await fireEvent.click(screen.getByRole('button', { name: /track actions/i }));
|
||||
await fireEvent.click(screen.getByRole('menuitem', { name: /^hide$/i }));
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: /flag this track as broken/i })
|
||||
|
||||
@@ -83,6 +83,23 @@ let _radioRefreshInFlight = false;
|
||||
// queue clears it).
|
||||
let _queueSource = $state<string | null>(null);
|
||||
|
||||
// Track B (#968): consecutive load failures since the last successful
|
||||
// 'playing'. A bad track auto-advances instead of dead-ending; the streak
|
||||
// (capped against queue length) stops a fully-unplayable queue from
|
||||
// looping forever. Reset on a successful play and on a fresh playQueue.
|
||||
let _failureStreak = 0;
|
||||
|
||||
// Track B (#968): when a queue is seeded from a refreshable source (a system
|
||||
// playlist or radio), the seeder hands us a closure that re-pulls the fresh
|
||||
// snapshot. On total failure (whole queue unplayable — likely a stale tab
|
||||
// left open across the daily rebuild) we call it once to self-heal before
|
||||
// surfacing the dead-end. Kept opaque so the store stays decoupled from the
|
||||
// playlist API and the per-artist (songs_like_artist) identity problem.
|
||||
let _queueRefetch: (() => Promise<TrackRef[]>) | null = null;
|
||||
let _selfHealInFlight = false;
|
||||
let _selfHealAttempts = 0;
|
||||
const MAX_SELF_HEAL_ATTEMPTS = 1;
|
||||
|
||||
let _audioEl: HTMLAudioElement | null = null;
|
||||
|
||||
export const player = {
|
||||
@@ -109,13 +126,20 @@ export function registerAudioEl(el: HTMLAudioElement | null): void {
|
||||
export function playQueue(
|
||||
tracks: TrackRef[],
|
||||
startIndex = 0,
|
||||
opts: { shuffle?: boolean; source?: string | null } = {},
|
||||
opts: {
|
||||
shuffle?: boolean;
|
||||
source?: string | null;
|
||||
refetch?: () => Promise<TrackRef[]>;
|
||||
} = {},
|
||||
): void {
|
||||
_radioSeedId = null; // M4c: non-radio enqueue clears the radio refresh state
|
||||
// #415: a fresh queue resets the system-playlist source. Set only
|
||||
// when seeded from a system playlist (server already returned the
|
||||
// rotation-aware order, so no client shuffle in that path).
|
||||
_queueSource = opts.source ?? null;
|
||||
// #968: a fresh play replaces the self-heal closure + resets its budget.
|
||||
_queueRefetch = opts.refetch ?? null;
|
||||
_selfHealAttempts = 0;
|
||||
if (opts.shuffle && tracks.length > 1) {
|
||||
// Fisher-Yates over the whole list. startIndex is ignored — the
|
||||
// caller is asking for "random play from this pool," so the first
|
||||
@@ -142,6 +166,7 @@ export function playQueue(
|
||||
_position = 0;
|
||||
_duration = 0;
|
||||
_error = null;
|
||||
_failureStreak = 0;
|
||||
}
|
||||
|
||||
export function togglePlay(): void {
|
||||
@@ -263,6 +288,8 @@ export function reportStateFromAudio(
|
||||
case 'playing':
|
||||
_state = 'playing';
|
||||
_error = null;
|
||||
_failureStreak = 0;
|
||||
_selfHealAttempts = 0;
|
||||
return;
|
||||
case 'paused':
|
||||
_state = 'paused';
|
||||
@@ -271,8 +298,7 @@ export function reportStateFromAudio(
|
||||
_state = 'loading';
|
||||
return;
|
||||
case 'error':
|
||||
_state = 'error';
|
||||
_error = detail ?? 'Playback failed.';
|
||||
handleLoadFailure(detail);
|
||||
return;
|
||||
case 'ended':
|
||||
if (_repeat === 'one') {
|
||||
@@ -301,6 +327,68 @@ export function reportStateFromAudio(
|
||||
}
|
||||
}
|
||||
|
||||
// Track B (#968): a single failed track must not strand the player on the
|
||||
// "Try again" dead-end. Advance past it; only surface the error once the
|
||||
// whole queue has proven unplayable — every track failed, or we reached the
|
||||
// end with nothing playable. The streak cap (vs queue length) stops a
|
||||
// fully-broken queue from cycling forever instead of settling.
|
||||
function handleLoadFailure(detail?: string): void {
|
||||
_failureStreak++;
|
||||
if (_failureStreak < _queue.length && _index + 1 < _queue.length) {
|
||||
_index++;
|
||||
_position = 0;
|
||||
_duration = 0;
|
||||
_state = 'loading';
|
||||
_error = null;
|
||||
return;
|
||||
}
|
||||
// Whole queue is unplayable. If it came from a refreshable source, the
|
||||
// snapshot is probably stale — re-pull it and resume before giving up.
|
||||
if (trySelfHeal()) return;
|
||||
_state = 'error';
|
||||
_error = detail ?? 'Playback failed.';
|
||||
}
|
||||
|
||||
// #968: re-pull a stale refreshable queue. Returns true if a self-heal was
|
||||
// started (caller must not dead-end). Bounded to one attempt per exhaustion
|
||||
// (reset on the next successful play) so a still-broken refresh can't loop.
|
||||
function trySelfHeal(): boolean {
|
||||
if (_selfHealInFlight) return true;
|
||||
if (_queueRefetch === null) return false;
|
||||
if (_selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) return false;
|
||||
_selfHealAttempts++;
|
||||
_selfHealInFlight = true;
|
||||
_state = 'loading';
|
||||
_error = null;
|
||||
const refetch = _queueRefetch;
|
||||
void refetch()
|
||||
.then((refs) => {
|
||||
if (refs.length > 0) {
|
||||
// Re-seed in place — keep _queueSource / _queueRefetch so the new
|
||||
// snapshot can itself self-heal, and don't reset _selfHealAttempts
|
||||
// (only a successful 'playing' clears the budget).
|
||||
_queue = refs;
|
||||
_index = 0;
|
||||
_position = 0;
|
||||
_duration = 0;
|
||||
_failureStreak = 0;
|
||||
_state = 'loading';
|
||||
_error = null;
|
||||
} else {
|
||||
_state = 'error';
|
||||
_error = 'Nothing playable in this mix right now.';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
_state = 'error';
|
||||
_error = 'Couldn’t refresh this mix. Try again.';
|
||||
})
|
||||
.finally(() => {
|
||||
_selfHealInFlight = false;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export function enqueueTrack(t: TrackRef): void {
|
||||
_radioSeedId = null; // M4c
|
||||
_queue = [..._queue, t];
|
||||
@@ -345,12 +433,19 @@ export function playNextMany(ts: TrackRef[]): void {
|
||||
_queue = [..._queue.slice(0, next), ...ts, ..._queue.slice(next)];
|
||||
}
|
||||
|
||||
export async function playRadio(seedTrackId: string): Promise<void> {
|
||||
async function fetchRadioTracks(seedTrackId: string): Promise<TrackRef[]> {
|
||||
const resp = await api.get<RadioResponse>(
|
||||
`/api/radio?seed_track=${encodeURIComponent(seedTrackId)}`
|
||||
);
|
||||
if (resp.tracks.length === 0) return;
|
||||
playQueue(resp.tracks, 0);
|
||||
return resp.tracks;
|
||||
}
|
||||
|
||||
export async function playRadio(seedTrackId: string): Promise<void> {
|
||||
const tracks = await fetchRadioTracks(seedTrackId);
|
||||
if (tracks.length === 0) return;
|
||||
// #968: hand the player a self-heal closure so a fully-stale radio queue
|
||||
// re-seeds from the same track instead of dead-ending.
|
||||
playQueue(tracks, 0, { refetch: () => fetchRadioTracks(seedTrackId) });
|
||||
_radioSeedId = seedTrackId; // M4c: set AFTER playQueue (which clears it)
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,50 @@ describe('player store — shuffle + repeat + audio reports', () => {
|
||||
expect(player.state).toBe('error');
|
||||
expect(player.error).toBe('network lost');
|
||||
});
|
||||
|
||||
test('reportStateFromAudio("error") auto-advances past a bad track', () => {
|
||||
playQueue([track('1'), track('2'), track('3')]);
|
||||
reportStateFromAudio('error', 'boom');
|
||||
expect(player.index).toBe(1);
|
||||
expect(player.state).toBe('loading');
|
||||
expect(player.error).toBeNull();
|
||||
});
|
||||
|
||||
test('reportStateFromAudio("error") dead-ends once the whole queue is unplayable', () => {
|
||||
playQueue([track('1'), track('2')]);
|
||||
reportStateFromAudio('error', 'boom'); // track 1 fails → skip to track 2
|
||||
expect(player.index).toBe(1);
|
||||
expect(player.state).toBe('loading');
|
||||
reportStateFromAudio('error', 'boom'); // track 2 also fails → exhausted
|
||||
expect(player.state).toBe('error');
|
||||
expect(player.error).toBe('boom');
|
||||
});
|
||||
|
||||
test('reportStateFromAudio("error") self-heals a refreshable queue on total failure', async () => {
|
||||
const fresh = [track('a'), track('b')];
|
||||
const refetch = vi.fn().mockResolvedValue(fresh);
|
||||
playQueue([track('1')], 0, { source: 'for_you', refetch });
|
||||
reportStateFromAudio('error', 'boom'); // sole track fails → exhausted → self-heal
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
expect(player.state).toBe('loading');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(player.queue.map((t) => t.id)).toEqual(['a', 'b']);
|
||||
expect(player.index).toBe(0);
|
||||
});
|
||||
|
||||
test('self-heal fires at most once per exhaustion, then dead-ends', async () => {
|
||||
const refetch = vi.fn().mockResolvedValue([track('x')]);
|
||||
playQueue([track('1')], 0, { source: 'for_you', refetch });
|
||||
reportStateFromAudio('error', 'boom'); // exhausted → self-heal #1
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
reportStateFromAudio('error', 'boom'); // re-pulled track also fails; budget spent → error
|
||||
expect(player.state).toBe('error');
|
||||
expect(refetch).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { TrackRef, PlaylistDetail } from '$lib/api/types';
|
||||
import { systemShuffle, getPlaylist } from '$lib/api/playlists';
|
||||
import { playlistTrackToRef } from './playlistTrackToRef';
|
||||
|
||||
// #968: builds the self-heal closure for a system-playlist queue. On total
|
||||
// playback failure (every queued track unplayable — typically a stale tab
|
||||
// left open across the daily rebuild) the player calls this to re-pull the
|
||||
// current snapshot and resume.
|
||||
//
|
||||
// Single-instance variants (for_you, discover, deep_cuts, …) re-pull by
|
||||
// variant via the rotation-aware shuffle endpoint. Per-artist mixes
|
||||
// (songs_like_artist — one playlist per seed artist) can't be addressed by
|
||||
// variant alone, so they re-pull by playlist id. Mirrors the play routing
|
||||
// in PlaylistCard.
|
||||
|
||||
function toRefs(detail: PlaylistDetail): TrackRef[] {
|
||||
return detail.tracks
|
||||
.map((r) => playlistTrackToRef(r))
|
||||
.filter((t): t is TrackRef => t !== null);
|
||||
}
|
||||
|
||||
export function systemPlaylistRefetch(opts: {
|
||||
variant: string;
|
||||
playlistId: string;
|
||||
perArtist: boolean;
|
||||
}): () => Promise<TrackRef[]> {
|
||||
return async () => {
|
||||
const detail = opts.perArtist
|
||||
? await getPlaylist(opts.playlistId)
|
||||
: await systemShuffle(opts.variant);
|
||||
return toRefs(detail);
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
import { useMediaSession } from '$lib/player/mediaSession.svelte';
|
||||
import { useEventsDispatcher } from '$lib/player/events.svelte';
|
||||
import { useServerEvents } from '$lib/serverEvents.svelte';
|
||||
import { useGlobalShortcuts } from '$lib/player/shortcuts.svelte';
|
||||
import { applyMetaThemeColor } from '$lib/theme/applyMetaThemeColor.svelte';
|
||||
import { audioLoader } from '$lib/player/audioLoader';
|
||||
@@ -132,6 +133,7 @@
|
||||
|
||||
useMediaSession();
|
||||
useEventsDispatcher();
|
||||
useServerEvents();
|
||||
useGlobalShortcuts();
|
||||
applyMetaThemeColor();
|
||||
</script>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import { FALLBACK_COVER, coverUrl } from '$lib/media/covers';
|
||||
import { useSmoothPosition } from '$lib/player/smoothPosition.svelte';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import TrackMenu from '$lib/components/TrackMenu.svelte';
|
||||
import QueueList from '$lib/components/QueueList.svelte';
|
||||
import { pageTitle } from '$lib/branding';
|
||||
|
||||
@@ -78,6 +79,15 @@
|
||||
<ArrowLeft size={22} strokeWidth={1.5} />
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{#if !current}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
@@ -12,12 +13,15 @@
|
||||
deletePlaylist,
|
||||
removePlaylistTrack,
|
||||
reorderPlaylist,
|
||||
refreshSystem
|
||||
refreshSystem,
|
||||
systemShuffle
|
||||
} from '$lib/api/playlists';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { user } from '$lib/auth/store.svelte';
|
||||
import { errCode, errMessage } from '$lib/api/errors';
|
||||
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 type { TrackRef } from '$lib/api/types';
|
||||
|
||||
@@ -61,16 +65,31 @@
|
||||
|
||||
function onPlay(position: number) {
|
||||
if (!playlistQuery?.data) return;
|
||||
const data = playlistQuery.data;
|
||||
// Skip rows where track_id is null (track was removed from library).
|
||||
const live = playlistQuery.data.tracks
|
||||
.filter((t) => t.track_id !== null)
|
||||
.map(toTrackRef);
|
||||
const live = data.tracks.filter((t) => t.track_id !== null).map(toTrackRef);
|
||||
// Find which `live` index corresponds to the clicked position. Some
|
||||
// positions in the original list may be unavailable (filtered out),
|
||||
// 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);
|
||||
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: {
|
||||
@@ -158,6 +177,40 @@
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -171,6 +224,24 @@
|
||||
<ApiErrorBanner error={playlistQuery.error} onRetry={() => playlistQuery.refetch()} />
|
||||
{:else if 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}
|
||||
<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">
|
||||
|
||||
@@ -41,7 +41,6 @@ vi.mock('$lib/api/likes', () => ({
|
||||
unlikeEntity: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/quarantine', () => emptyQuarantineMock());
|
||||
vi.mock('$lib/api/admin/tracks', () => ({ removeTrack: vi.fn() }));
|
||||
|
||||
import PlaylistDetailPage from './+page.svelte';
|
||||
import { createPlaylistQuery, deletePlaylist, refreshSystem } from '$lib/api/playlists';
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
createEnabledMutation,
|
||||
type LBStatus
|
||||
} from '$lib/api/listenbrainz';
|
||||
import {
|
||||
createRecommendationMetricsQuery,
|
||||
sourceLabel,
|
||||
type RecommendationMetrics
|
||||
} from '$lib/api/metrics';
|
||||
import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte';
|
||||
import { player, setCrossfade } from '$lib/player/store.svelte';
|
||||
import {
|
||||
@@ -24,6 +29,7 @@
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const status = createLBStatusQuery() as CreateQueryResult<LBStatus>;
|
||||
const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>;
|
||||
const tokenMutation = createTokenMutation(queryClient);
|
||||
const enabledMutation = createEnabledMutation(queryClient);
|
||||
|
||||
@@ -267,6 +273,46 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Recommendation metrics card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Recommendation metrics</h2>
|
||||
<p class="text-sm text-text-secondary">
|
||||
How plays launched from each recommendation surface land, over the last
|
||||
{$metrics.data?.window_days ?? 30} days. Lower skip rate and higher average
|
||||
completion mean the surface is hitting.
|
||||
</p>
|
||||
{#if $metrics.isPending}
|
||||
<p class="text-sm text-text-secondary">Loading…</p>
|
||||
{:else if $metrics.isError}
|
||||
<p class="text-sm text-text-secondary">Couldn't load metrics.</p>
|
||||
{:else if $metrics.data && $metrics.data.sources.length > 0}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-text-secondary">
|
||||
<th class="py-1 font-medium">Surface</th>
|
||||
<th class="py-1 text-right font-medium">Plays</th>
|
||||
<th class="py-1 text-right font-medium">Skip rate</th>
|
||||
<th class="py-1 text-right font-medium">Avg completion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $metrics.data.sources as m (m.source)}
|
||||
<tr class="border-t border-border">
|
||||
<td class="py-1">{sourceLabel(m.source)}</td>
|
||||
<td class="py-1 text-right tabular-nums">{m.plays}</td>
|
||||
<td class="py-1 text-right tabular-nums">{(m.skip_rate * 100).toFixed(0)}%</td>
|
||||
<td class="py-1 text-right tabular-nums">{(m.avg_completion * 100).toFixed(0)}%</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="text-sm text-text-secondary">
|
||||
No recommendation plays yet. Play something from For You, Discover, or a mix.
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Profile card -->
|
||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||
<h2 class="text-lg font-semibold">Profile</h2>
|
||||
|
||||
@@ -25,6 +25,16 @@ vi.mock('$lib/api/listenbrainz', () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/api/metrics', () => ({
|
||||
createRecommendationMetricsQuery: () => ({
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } });
|
||||
return () => {};
|
||||
}
|
||||
}),
|
||||
sourceLabel: (s: string) => s
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
|
||||
@@ -20,6 +20,16 @@ vi.mock('$lib/api/me', () => ({
|
||||
regenerateAPIToken: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/metrics', () => ({
|
||||
createRecommendationMetricsQuery: () => ({
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } });
|
||||
return () => {};
|
||||
}
|
||||
}),
|
||||
sourceLabel: (s: string) => s
|
||||
}));
|
||||
|
||||
import SettingsPage from './+page.svelte';
|
||||
import {
|
||||
createLBStatusQuery,
|
||||
|
||||
Reference in New Issue
Block a user