Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b7fa635d8 | ||
|
|
78aa9befb6 | ||
|
|
a9ca49dc4e | ||
|
|
feb1c2eca8 | ||
|
|
f8f2273aec | ||
|
|
1126bfcf78 | ||
|
|
5b36d79ff9 | ||
|
|
11538095be | ||
|
|
d5ab3b0764 | ||
|
|
a07fb3867a | ||
|
|
381e9cedb7 | ||
|
|
bf649f3beb | ||
|
|
d86af7397d | ||
|
|
2e1a8a62d8 | ||
|
|
1bf0e388cb | ||
|
|
a4b6f22d86 | ||
|
|
57d2299180 | ||
|
|
8b630e71ca | ||
|
|
1910a5ce61 | ||
|
|
a92a9f2198 | ||
|
|
6dea45a634 | ||
|
|
fa7ea41ccf | ||
|
|
e1e591b520 | ||
|
|
324059b2bd | ||
|
|
eec59193fa | ||
|
|
ca4832e620 | ||
|
|
cf0d37bf8e | ||
|
|
799dab029a | ||
|
|
7315e37c15 | ||
|
|
4f9b083eec | ||
|
|
f17356560d | ||
|
|
18a61f1065 | ||
|
|
6e39471a70 | ||
|
|
86af79bd2f | ||
|
|
e006de5d4b | ||
|
|
94e2cac03b | ||
|
|
b27029f674 | ||
|
|
14aa22198f | ||
|
|
1138d75a45 | ||
|
|
cf7b489fec | ||
|
|
8483948f23 | ||
|
|
0cea82984c | ||
|
|
3acac985cd | ||
|
|
d3b40342b4 | ||
|
|
4f99b42844 | ||
|
|
5044e7a055 | ||
|
|
7d45a4e5c7 | ||
|
|
fa0827f668 | ||
|
|
52d53e0044 | ||
|
|
a26ef4e93c | ||
|
|
0774f5f55f | ||
|
|
509cbe79b2 | ||
|
|
dc7b9b78fa | ||
|
|
cde74b5965 | ||
|
|
0efbf5fcaa | ||
|
|
2038028d42 | ||
|
|
723293110d | ||
|
|
41ebf1405b | ||
|
|
f2dcf2596d | ||
|
|
47de7be472 | ||
|
|
659554df0e | ||
|
|
2ecdd46a2b | ||
|
|
41fe76b90c | ||
|
|
6912dadf2b | ||
|
|
304e06acc8 | ||
|
|
235839b696 | ||
|
|
4f69c230c4 | ||
|
|
5749f48b4a | ||
|
|
f0c08e7326 | ||
|
|
199fec2058 | ||
|
|
65dd132b3d | ||
|
|
40384cc05e | ||
|
|
40056d2e9a | ||
|
|
2b3be8311a | ||
|
|
c30511e71b | ||
|
|
797ed1f5ad | ||
|
|
96c2eb6afb | ||
|
|
cce928e584 | ||
|
|
7d18a3c808 | ||
|
|
c34753f5b0 | ||
|
|
fbfd5550ff | ||
|
|
d6ee5a304d | ||
|
|
d497c57d6c | ||
|
|
2e92ba498c | ||
|
|
c1d143cf4a | ||
|
|
cb0af5efd3 | ||
|
|
20a76f4b39 | ||
|
|
7226dab9ff | ||
|
|
772a52b23e | ||
|
|
725ddca950 | ||
|
|
29e1e7c64c | ||
|
|
9ad4343c76 | ||
|
|
0d0a8f46b1 | ||
|
|
9e02878b61 | ||
|
|
48f288e2e5 | ||
|
|
2be07ef271 | ||
|
|
a670840114 | ||
|
|
5faa57634b | ||
|
|
fb4431207d | ||
|
|
60533073ad | ||
|
|
4b150a277e |
@@ -80,10 +80,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload debug APK
|
- name: Upload debug APK
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
# Gitea Actions runs in GHES-emulation mode; @actions/artifact v2+
|
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||||
# (i.e. upload-artifact@v4+) errors with "GHESNotSupportedError".
|
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||||
# Pin to @v3 until act_runner or the artifact backend catches up.
|
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||||
uses: actions/upload-artifact@v3
|
# serves artifacts back only through the v4 API, so the upload is stored
|
||||||
|
# and invisible to every retrieval path. @v3 is what left 72 unreachable
|
||||||
|
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
|
||||||
|
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
|
||||||
|
# See Scribe issues 2255 / 2270.
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
with:
|
with:
|
||||||
name: minstrel-android-debug-${{ github.sha }}
|
name: minstrel-android-debug-${{ github.sha }}
|
||||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
|||||||
@@ -131,12 +131,19 @@ jobs:
|
|||||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||||
|
|
||||||
- name: Upload APK as workflow artifact
|
- name: Upload APK as workflow artifact
|
||||||
# @v3 because Gitea Actions emulates GHES and the v2 artifact
|
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||||
# backend used by upload-artifact@v4 errors with GHESNotSupportedError.
|
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||||
uses: actions/upload-artifact@v3
|
# the producing half of a pair: image-release downloads `minstrel-apk`
|
||||||
|
# below with the matching download-artifact mirror. Both must stay on
|
||||||
|
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
||||||
|
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
with:
|
with:
|
||||||
name: minstrel-apk
|
name: minstrel-apk
|
||||||
path: android/app/build/outputs/apk/release/app-release.apk
|
path: android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
# error, not the default warn: image-release hard-depends on this
|
||||||
|
# artifact existing, so an empty upload must fail here, not there.
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Attach APK to gitea Release
|
- name: Attach APK to gitea Release
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -238,7 +245,20 @@ jobs:
|
|||||||
# Tag pushes only — android-release just produced this. Non-tag
|
# Tag pushes only — android-release just produced this. Non-tag
|
||||||
# builds take the "Bundle latest release APK" path below instead.
|
# builds take the "Bundle latest release APK" path below instead.
|
||||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: actions/download-artifact@v3
|
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||||
|
# same reason: upstream's client-side GHES check rejects this hostname
|
||||||
|
# before it connects. bvandeusen/download-artifact mirrors
|
||||||
|
# code.forgejo.org/forgejo/download-artifact.
|
||||||
|
#
|
||||||
|
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
|
||||||
|
# the action's own version number — the two actions release on unrelated
|
||||||
|
# cadences, and download v5 would pair a ^2.3.2 client with this file's
|
||||||
|
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
|
||||||
|
# the same one proven against this instance by the upload side.
|
||||||
|
# Deliberately NOT v7: it moves to node24 and upstream requires runner
|
||||||
|
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
|
||||||
|
# Pinned, not tagged — the mirror auto-syncs every 8h.
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||||
with:
|
with:
|
||||||
name: minstrel-apk
|
name: minstrel-apk
|
||||||
path: client/
|
path: client/
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ on:
|
|||||||
- 'go.mod'
|
- 'go.mod'
|
||||||
- 'go.sum'
|
- 'go.sum'
|
||||||
- 'sqlc.yaml'
|
- 'sqlc.yaml'
|
||||||
|
- 'Makefile'
|
||||||
- 'internal/**'
|
- 'internal/**'
|
||||||
- 'cmd/**'
|
- 'cmd/**'
|
||||||
- '.golangci.yml'
|
- '.golangci.yml'
|
||||||
@@ -53,6 +54,9 @@ jobs:
|
|||||||
go version
|
go version
|
||||||
golangci-lint --version
|
golangci-lint --version
|
||||||
|
|
||||||
|
- name: Generated code matches queries (sqlc)
|
||||||
|
run: make verify-generate
|
||||||
|
|
||||||
- name: go vet
|
- name: go vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
.PHONY: generate test test-short test-integration lint build
|
.PHONY: generate generate-go verify-generate test test-short test-integration lint build
|
||||||
|
|
||||||
|
# renovate: datasource=docker depName=sqlc/sqlc
|
||||||
SQLC_VERSION := 1.31.1
|
SQLC_VERSION := 1.31.1
|
||||||
|
|
||||||
|
# Local codegen. Containerised so a dev needs no sqlc install.
|
||||||
generate:
|
generate:
|
||||||
docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate
|
docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate
|
||||||
|
|
||||||
|
# Same codegen, run as a Go tool instead of a container. This is the CI path:
|
||||||
|
# the ci-go image already has Go, so it avoids docker-in-docker. Pinned to the
|
||||||
|
# SAME version as `generate` above so both routes emit identical output.
|
||||||
|
generate-go:
|
||||||
|
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) generate
|
||||||
|
|
||||||
|
# Fail if the committed generated code no longer matches the .sql sources.
|
||||||
|
#
|
||||||
|
# Nothing verified this before, so internal/db/dbq could silently drift from
|
||||||
|
# internal/db/queries — a hand-edit, a half-applied regen, or a schema change
|
||||||
|
# without a regen would all pass CI while the typed layer lied about the SQL.
|
||||||
|
#
|
||||||
|
# The diff is printed BEFORE the exit-code check on purpose: when this fails,
|
||||||
|
# the log then contains sqlc's exact expected output, which is what you commit.
|
||||||
|
verify-generate: generate-go
|
||||||
|
# -N (intent-to-add) so a BRAND-NEW generated file is visible to `git
|
||||||
|
# diff`, which otherwise ignores untracked paths entirely — a whole
|
||||||
|
# missing *.sql.go would sail through the check below.
|
||||||
|
git add -N -- internal/db/dbq
|
||||||
|
git --no-pager diff -- internal/db/dbq
|
||||||
|
git diff --quiet -- internal/db/dbq
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,22 @@ A self-hosted music server that thinks for you. Smart shuffle, contextual likes,
|
|||||||
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
- **OpenSubsonic-compatible.** Existing Subsonic clients (DSub, Symfonium, play:Sub, etc.) connect with no special configuration.
|
||||||
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
|
- **Server-side smart shuffle.** Track-similarity vectors, dual-like model (general + contextual), and session memory keep mixes coherent across devices.
|
||||||
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
- **ListenBrainz radio.** Session-aware "more like this" pulls from ListenBrainz similarity data, not a static genre tag.
|
||||||
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit.
|
- **Lidarr integration.** Triggered scans, request-driven album imports, and a quarantine flow when something doesn't fit — against a Lidarr instance *you* run and configure. Optional, and off until you supply a URL and API key.
|
||||||
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
- **Built-in web SPA.** Full-feature library, search, queue, playlists, and admin — no separate frontend container to deploy.
|
||||||
- **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).
|
- **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).
|
||||||
|
|
||||||
|
## Scope and responsible use
|
||||||
|
|
||||||
|
**Minstrel serves music you already have.** It is a library server: it indexes files on disk you point it at, and streams them to your own clients. It does not source, search for, or acquire content, and it has no opinion about where your files came from.
|
||||||
|
|
||||||
|
Concretely, Minstrel ships **no** indexers, **no** trackers, **no** torrent / Usenet / NZB client, and **no** DRM circumvention of any kind. There is nothing to point at a content source because Minstrel has no such subsystem.
|
||||||
|
|
||||||
|
The **Lidarr integration is optional and inert until you configure it.** You supply the URL and API key of a Lidarr instance you are already running; Minstrel then calls that instance's API to trigger scans, submit album requests, and reconcile imports. Minstrel neither bundles nor installs Lidarr, and configures no indexers on your behalf — Lidarr ships with none either, and any it uses are ones you added yourself.
|
||||||
|
|
||||||
|
**What you put in your library, and what sources you configure in your own Lidarr, are your responsibility.** Copyright law applies to your collection the same way it applies to any other software that plays a file. Please respect it, and respect the terms of any service you connect.
|
||||||
|
|
||||||
|
Minstrel is not affiliated with or endorsed by Lidarr, ListenBrainz, MusicBrainz, or Subsonic.
|
||||||
|
|
||||||
## Quickstart
|
## Quickstart
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|||||||
@@ -210,4 +210,17 @@ dependencies {
|
|||||||
debugImplementation(libs.compose.ui.test.manifest)
|
debugImplementation(libs.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test> { useJUnitPlatform() }
|
tasks.withType<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
// Print the assertion message + full stack trace for failures. The
|
||||||
|
// default console output gives only "AssertionError at Foo.kt:12", and
|
||||||
|
// for a failure inside a `runTest { }` lambda even that line collapses
|
||||||
|
// to the test function's own line (the assertion frames live in the
|
||||||
|
// suspend-lambda class, which Gradle filters out) — leaving nothing to
|
||||||
|
// debug from when the HTML report isn't reachable, as in CI.
|
||||||
|
testLogging {
|
||||||
|
events("failed")
|
||||||
|
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||||
|
showStackTraces = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,16 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<!-- In-app self-update. REQUEST_INSTALL_PACKAGES lets us hand an APK to the
|
||||||
|
platform installer at all; UPDATE_PACKAGES_WITHOUT_USER_ACTION (API 31+)
|
||||||
|
is what lets that install happen with NO confirm dialog. The platform
|
||||||
|
grants the silent path only when the installer opts in via
|
||||||
|
SessionParams.setRequireUserAction(USER_ACTION_NOT_REQUIRED), the
|
||||||
|
installed app targets API 29+, the installer holds this permission, and
|
||||||
|
the target is the installer itself — all true here, since Minstrel is
|
||||||
|
updating Minstrel. See update/data/SelfUpdateSession.kt. -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||||
|
|
||||||
@@ -19,9 +28,9 @@
|
|||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.Minstrel"
|
android:theme="@style/Theme.Minstrel"
|
||||||
android:usesCleartextTraffic="true"
|
|
||||||
tools:targetApi="34">
|
tools:targetApi="34">
|
||||||
|
|
||||||
<!-- Portrait-locked until a tablet/landscape layout exists.
|
<!-- Portrait-locked until a tablet/landscape layout exists.
|
||||||
@@ -48,15 +57,11 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
<provider
|
<!-- The FileProvider that used to live here existed solely to expose the
|
||||||
android:name="androidx.core.content.FileProvider"
|
downloaded update APK as a content:// URI for the old ACTION_VIEW
|
||||||
android:authorities="${applicationId}.fileprovider"
|
install intent. A PackageInstaller session takes a stream instead,
|
||||||
android:exported="false"
|
so both the provider and res/xml/file_paths.xml are gone — nothing
|
||||||
android:grantUriPermissions="true">
|
else in the app ever used that authority. -->
|
||||||
<meta-data
|
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
|
||||||
android:resource="@xml/file_paths" />
|
|
||||||
</provider>
|
|
||||||
|
|
||||||
<!-- On-demand WorkManager initialization: MinstrelApplication
|
<!-- On-demand WorkManager initialization: MinstrelApplication
|
||||||
implements Configuration.Provider and supplies the
|
implements Configuration.Provider and supplies the
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.work.Configuration
|
|||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import coil3.request.crossfade
|
||||||
import com.fabledsword.minstrel.cache.CacheIndexer
|
import com.fabledsword.minstrel.cache.CacheIndexer
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
@@ -29,6 +30,10 @@ import okhttp3.OkHttpClient
|
|||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
// Cover-art fade-in. Coil skips the transition for memory-cache hits, so
|
||||||
|
// already-loaded art still appears instantly — only a genuine fetch fades.
|
||||||
|
private const val ART_CROSSFADE_MS = 220
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class MinstrelApplication :
|
class MinstrelApplication :
|
||||||
Application(),
|
Application(),
|
||||||
@@ -213,11 +218,18 @@ class MinstrelApplication :
|
|||||||
* OkHttp client as the network fetcher. The `callFactory` lambda
|
* OkHttp client as the network fetcher. The `callFactory` lambda
|
||||||
* is invoked lazily so Hilt has time to inject `okHttpClient`
|
* is invoked lazily so Hilt has time to inject `okHttpClient`
|
||||||
* before Coil makes its first request.
|
* before Coil makes its first request.
|
||||||
|
*
|
||||||
|
* Crossfade is set here rather than per-call so every cover surface
|
||||||
|
* in the app fades its artwork in instead of snapping it. Art
|
||||||
|
* landing a beat after its tile was the most visible pop-in on Home
|
||||||
|
* (issue #2327); `ServerImage` fades its placeholder out over the
|
||||||
|
* same window so the two read as one cross-dissolve.
|
||||||
*/
|
*/
|
||||||
override fun newImageLoader(context: android.content.Context): ImageLoader =
|
override fun newImageLoader(context: android.content.Context): ImageLoader =
|
||||||
ImageLoader.Builder(context)
|
ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
||||||
}
|
}
|
||||||
|
.crossfade(ART_CROSSFADE_MS)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
|
||||||
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-through accessor for the tag-enrichment provider settings (#1521).
|
||||||
|
* No Room caching — admin settings are infrequent point-and-shoot edits;
|
||||||
|
* mutations fire direct REST and the ViewModel reconciles on failure.
|
||||||
|
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AdminTagSourcesRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: AdminTagSourcesApi = retrofit.create()
|
||||||
|
|
||||||
|
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
|
||||||
|
|
||||||
|
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
|
||||||
|
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
|
||||||
|
|
||||||
|
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
|
||||||
|
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
|
||||||
|
|
||||||
|
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
|
||||||
|
id = id,
|
||||||
|
displayName = displayName,
|
||||||
|
requiresApiKey = requiresApiKey,
|
||||||
|
supports = supports,
|
||||||
|
enabled = enabled,
|
||||||
|
apiKeySet = apiKeySet,
|
||||||
|
testable = testable,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
|
||||||
|
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
|
||||||
@@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.composables.icons.lucide.Inbox
|
import com.composables.icons.lucide.Inbox
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Music
|
||||||
import com.composables.icons.lucide.TriangleAlert
|
import com.composables.icons.lucide.TriangleAlert
|
||||||
import com.composables.icons.lucide.Users
|
import com.composables.icons.lucide.Users
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
|
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.nav.Admin
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
|
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||||
import com.fabledsword.minstrel.nav.AdminUsers
|
import com.fabledsword.minstrel.nav.AdminUsers
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -54,7 +57,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
|
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
|
||||||
|
|
||||||
sealed interface AdminLandingUiState {
|
sealed interface AdminLandingUiState {
|
||||||
data object Loading : AdminLandingUiState
|
data object Loading : AdminLandingUiState
|
||||||
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
private val requestsRepo: AdminRequestsRepository,
|
private val requestsRepo: AdminRequestsRepository,
|
||||||
private val quarantineRepo: AdminQuarantineRepository,
|
private val quarantineRepo: AdminQuarantineRepository,
|
||||||
private val usersRepo: AdminUsersRepository,
|
private val usersRepo: AdminUsersRepository,
|
||||||
|
private val tagSourcesRepo: AdminTagSourcesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||||
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
val req = async { requestsRepo.list().size }
|
val req = async { requestsRepo.list().size }
|
||||||
val qua = async { quarantineRepo.list().size }
|
val qua = async { quarantineRepo.list().size }
|
||||||
val usr = async { usersRepo.list().size }
|
val usr = async { usersRepo.list().size }
|
||||||
AdminCounts(req.await(), qua.await(), usr.await())
|
val tag = async { tagSourcesRepo.list().count { it.enabled } }
|
||||||
|
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
|
||||||
}
|
}
|
||||||
internal.value = AdminLandingUiState.Success(counts)
|
internal.value = AdminLandingUiState.Success(counts)
|
||||||
} catch (
|
} catch (
|
||||||
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
|||||||
onClick = { navController.navigate(AdminUsers) },
|
onClick = { navController.navigate(AdminUsers) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
SectionCard(
|
||||||
|
icon = Lucide.Music,
|
||||||
|
title = "Tag sources",
|
||||||
|
subtitle = "Metadata enrichment providers",
|
||||||
|
count = counts.tagSources,
|
||||||
|
onClick = { navController.navigate(AdminTagSources) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
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.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
|
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AdminTagSourcesScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
MinstrelTopAppBar(
|
||||||
|
title = "Admin · Tag sources",
|
||||||
|
navController = navController,
|
||||||
|
currentRouteName = AdminTagSources::class.qualifiedName,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
PullToRefreshScaffold(
|
||||||
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
AdminTagSourcesUiState.Loading -> LoadingCentered()
|
||||||
|
AdminTagSourcesUiState.Empty -> EmptyState(
|
||||||
|
title = "No tag sources",
|
||||||
|
body = "Tag-enrichment providers register on the server; none are available.",
|
||||||
|
)
|
||||||
|
is AdminTagSourcesUiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load tag sources",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
|
)
|
||||||
|
is AdminTagSourcesUiState.Success -> TagSourceList(
|
||||||
|
providers = s.providers,
|
||||||
|
testResults = s.testResults,
|
||||||
|
onToggle = viewModel::setEnabled,
|
||||||
|
onSaveKey = viewModel::saveApiKey,
|
||||||
|
onTest = viewModel::test,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TagSourceList(
|
||||||
|
providers: List<AdminTagSourceRef>,
|
||||||
|
testResults: Map<String, TagSourceTestResult>,
|
||||||
|
onToggle: (String, Boolean) -> Unit,
|
||||||
|
onSaveKey: (String, String) -> Unit,
|
||||||
|
onTest: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(items = providers, key = { it.id }) { provider ->
|
||||||
|
TagSourceCard(
|
||||||
|
provider = provider,
|
||||||
|
testResult = testResults[provider.id],
|
||||||
|
onToggle = onToggle,
|
||||||
|
onSaveKey = onSaveKey,
|
||||||
|
onTest = onTest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TagSourceCard(
|
||||||
|
provider: AdminTagSourceRef,
|
||||||
|
testResult: TagSourceTestResult?,
|
||||||
|
onToggle: (String, Boolean) -> Unit,
|
||||||
|
onSaveKey: (String, String) -> Unit,
|
||||||
|
onTest: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = provider.enabled,
|
||||||
|
onCheckedChange = { onToggle(provider.id, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (provider.requiresApiKey) {
|
||||||
|
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
|
||||||
|
}
|
||||||
|
if (provider.testable) {
|
||||||
|
TestRow(provider = provider, testResult = testResult, onTest = onTest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
|
||||||
|
var key by remember(provider.id) { mutableStateOf("") }
|
||||||
|
OutlinedTextField(
|
||||||
|
value = key,
|
||||||
|
onValueChange = { key = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("API key") },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
if (provider.apiKeySet) {
|
||||||
|
"••• saved — leave blank to keep"
|
||||||
|
} else {
|
||||||
|
"Paste your API key to enable this source"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSaveKey(provider.id, key)
|
||||||
|
key = ""
|
||||||
|
},
|
||||||
|
enabled = key.isNotBlank(),
|
||||||
|
) { Text("Save key") }
|
||||||
|
if (provider.apiKeySet) {
|
||||||
|
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TestRow(
|
||||||
|
provider: AdminTagSourceRef,
|
||||||
|
testResult: TagSourceTestResult?,
|
||||||
|
onTest: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
|
||||||
|
testResult?.let { result ->
|
||||||
|
if (result.ok) {
|
||||||
|
Text(
|
||||||
|
text = "OK (${result.durationMs}ms)",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Failed — ${result.error}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
sealed interface AdminTagSourcesUiState {
|
||||||
|
data object Loading : AdminTagSourcesUiState
|
||||||
|
data object Empty : AdminTagSourcesUiState
|
||||||
|
data class Success(
|
||||||
|
val providers: List<AdminTagSourceRef>,
|
||||||
|
val testResults: Map<String, TagSourceTestResult>,
|
||||||
|
) : AdminTagSourcesUiState
|
||||||
|
data class Error(val message: String) : AdminTagSourcesUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AdminTagSourcesViewModel @Inject constructor(
|
||||||
|
private val repository: AdminTagSourcesRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow<AdminTagSourcesUiState>(AdminTagSourcesUiState.Loading)
|
||||||
|
val uiState: StateFlow<AdminTagSourcesUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
// Screen-level auto-recovery: reload a failed list when server
|
||||||
|
// health returns instead of waiting for a manual pull (issue #1245).
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminTagSourcesUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
internal.value = AdminTagSourcesUiState.Loading
|
||||||
|
try {
|
||||||
|
val providers = repository.list()
|
||||||
|
internal.value = if (providers.isEmpty()) {
|
||||||
|
AdminTagSourcesUiState.Empty
|
||||||
|
} else {
|
||||||
|
AdminTagSourcesUiState.Success(providers, emptyMap())
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(id: String, enabled: Boolean) {
|
||||||
|
val before = internal.value as? AdminTagSourcesUiState.Success ?: return
|
||||||
|
// Optimistic toggle; reconcile via refresh() if the server rejects.
|
||||||
|
internal.value = before.copy(
|
||||||
|
providers = before.providers.map {
|
||||||
|
if (it.id == id) it.copy(enabled = enabled) else it
|
||||||
|
},
|
||||||
|
)
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.setEnabled(id, enabled)
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveApiKey(id: String, apiKey: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
replaceProvider(repository.setApiKey(id, apiKey))
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun test(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = try {
|
||||||
|
repository.test(id)
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch
|
||||||
|
internal.value = current.copy(testResults = current.testResults + (id to result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceProvider(updated: AdminTagSourceRef) {
|
||||||
|
val current = internal.value as? AdminTagSourcesUiState.Success ?: return
|
||||||
|
internal.value = current.copy(
|
||||||
|
providers = current.providers.map { if (it.id == updated.id) updated else it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.PATCH
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
|
||||||
|
* surface for the tag-enrichment provider settings (#1490). Mirrors the
|
||||||
|
* web integrations "Tag enrichment sources" card. Same shape as the
|
||||||
|
* cover-sources admin surface; kept independent so a new tag source is
|
||||||
|
* added without touching art settings.
|
||||||
|
*/
|
||||||
|
interface AdminTagSourcesApi {
|
||||||
|
@GET("api/admin/tag-sources")
|
||||||
|
suspend fun list(): AdminTagSourcesListWire
|
||||||
|
|
||||||
|
@PATCH("api/admin/tag-sources/{id}")
|
||||||
|
suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire
|
||||||
|
|
||||||
|
@POST("api/admin/tag-sources/{id}/test")
|
||||||
|
suspend fun test(@Path("id") id: String): TestTagSourceWire
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH body. Both fields are nullable + default-null so kotlinx omits the
|
||||||
|
* untouched one (the server reads a missing/null field as "leave
|
||||||
|
* unchanged"): send only `enabled` to toggle, only `apiKey` to set a key.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class UpdateTagSourceBody(
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
@SerialName("api_key") val apiKey: String? = null,
|
||||||
|
)
|
||||||
@@ -3,9 +3,13 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,4 +34,30 @@ interface DiscoverApi {
|
|||||||
|
|
||||||
@POST("api/requests")
|
@POST("api/requests")
|
||||||
suspend fun createRequest(@Body body: CreateRequestBody)
|
suspend fun createRequest(@Body body: CreateRequestBody)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parks a suggestion — "not right now", NOT a dislike. Time-boxed
|
||||||
|
* server-side (90 days) and never fed into the taste profile.
|
||||||
|
*
|
||||||
|
* [body] must carry the artist's name: candidates are out-of-library, so
|
||||||
|
* the server has no local row to resolve a display name from and returns
|
||||||
|
* 400 without it.
|
||||||
|
*/
|
||||||
|
@POST("api/discover/suggestions/{mbid}/snooze")
|
||||||
|
suspend fun snoozeSuggestion(
|
||||||
|
@Path("mbid") mbid: String,
|
||||||
|
@Body body: SnoozeSuggestionBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Brings a parked suggestion back. 404 when it wasn't snoozed. */
|
||||||
|
@DELETE("api/discover/suggestions/{mbid}/snooze")
|
||||||
|
suspend fun unsnoozeSuggestion(@Path("mbid") mbid: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently-parked suggestions. Server filters expired rows, so every
|
||||||
|
* row returned is still snoozed. This is the only route back to an
|
||||||
|
* un-snooze once the card has left the deck.
|
||||||
|
*/
|
||||||
|
@GET("api/discover/snoozes")
|
||||||
|
suspend fun listSnoozes(): List<SuggestionSnoozeWire>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -21,12 +22,32 @@ interface CachedHomeIndexDao {
|
|||||||
)
|
)
|
||||||
suspend fun getBySection(section: String): List<CachedHomeIndexEntity>
|
suspend fun getBySection(section: String): List<CachedHomeIndexEntity>
|
||||||
|
|
||||||
|
/** True when Home has any cached section rows to render. */
|
||||||
|
@Query("SELECT EXISTS(SELECT 1 FROM cached_home_index)")
|
||||||
|
suspend fun hasAny(): Boolean
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
|
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
|
||||||
|
|
||||||
/** Replace-all pattern; sync wipes a section then re-inserts. */
|
@Query("DELETE FROM cached_home_index WHERE section IN (:sections)")
|
||||||
@Query("DELETE FROM cached_home_index WHERE section = :section")
|
suspend fun deleteSections(sections: List<String>)
|
||||||
suspend fun deleteBySection(section: String)
|
|
||||||
|
/**
|
||||||
|
* Swaps every listed section's rows in ONE transaction.
|
||||||
|
*
|
||||||
|
* Atomicity is the point, not just tidiness: Room's
|
||||||
|
* InvalidationTracker only notifies observers after the transaction
|
||||||
|
* commits, so [observeBySection] never sees the empty gap between the
|
||||||
|
* delete and the re-insert. Replacing sections one at a time (and
|
||||||
|
* un-transacted) made each Home row emit `emptyList()` — visibly
|
||||||
|
* collapsing — before refilling, and made the seven sections do it in
|
||||||
|
* sequence rather than as a single content swap.
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
suspend fun replaceSections(sections: List<String>, rows: List<CachedHomeIndexEntity>) {
|
||||||
|
deleteSections(sections)
|
||||||
|
if (rows.isNotEmpty()) upsertAll(rows)
|
||||||
|
}
|
||||||
|
|
||||||
@Query("DELETE FROM cached_home_index")
|
@Query("DELETE FROM cached_home_index")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -35,10 +36,33 @@ interface CachedPlaylistTrackDao {
|
|||||||
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||||
suspend fun maxPosition(playlistId: String): Int?
|
suspend fun maxPosition(playlistId: String): Int?
|
||||||
|
|
||||||
/** Replace-all pattern for a playlist; called after a sync delta lands. */
|
|
||||||
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||||
suspend fun deleteByPlaylist(playlistId: String)
|
suspend fun deleteByPlaylist(playlistId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a playlist's whole membership in ONE transaction; called
|
||||||
|
* after a refresh or a sync delta lands.
|
||||||
|
*
|
||||||
|
* Atomic on purpose. Room's InvalidationTracker only notifies observers
|
||||||
|
* after the transaction commits, so [observeByPlaylist] never sees the
|
||||||
|
* empty gap between the delete and the re-insert. Un-transacted, that
|
||||||
|
* gap is a real observed state — it's what made every Home row visibly
|
||||||
|
* collapse to empty and refill before issue #2327 fixed the equivalent
|
||||||
|
* write in `CachedHomeIndexDao`.
|
||||||
|
*
|
||||||
|
* Nothing observes [observeByPlaylist] live today, so this is
|
||||||
|
* pre-emptive: it means making playlist detail cache-first later can't
|
||||||
|
* silently reintroduce that flicker.
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
suspend fun replacePlaylistTracks(
|
||||||
|
playlistId: String,
|
||||||
|
rows: List<CachedPlaylistTrackEntity>,
|
||||||
|
) {
|
||||||
|
deleteByPlaylist(playlistId)
|
||||||
|
if (rows.isNotEmpty()) upsertAll(rows)
|
||||||
|
}
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"DELETE FROM cached_playlist_tracks " +
|
"DELETE FROM cached_playlist_tracks " +
|
||||||
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
|
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ object MutationKind {
|
|||||||
// background avoids the duplicate + orphan row the old offline-on-stop
|
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||||
// path produced (see 2026-06-11 contract audit).
|
// path produced (see 2026-06-11 contract audit).
|
||||||
const val PLAY_ENDED: String = "play_ended"
|
const val PLAY_ENDED: String = "play_ended"
|
||||||
|
|
||||||
|
// #2374 suggestion snooze. ONE toggle kind rather than separate
|
||||||
|
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
|
||||||
|
// an undo collapses to the latest intent instead of replaying as two
|
||||||
|
// opposed calls whose order decides the outcome.
|
||||||
|
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,6 +158,25 @@ class MutationQueue @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a suggestion snooze (or its undo) for replay. [desiredSnoozed]
|
||||||
|
* is the TARGET state, so repeated taps collapse to one replay.
|
||||||
|
*
|
||||||
|
* [name] is carried even for an un-snooze, where the server ignores it,
|
||||||
|
* so a single payload shape serves both directions.
|
||||||
|
*/
|
||||||
|
suspend fun enqueueSuggestionSnoozeToggle(
|
||||||
|
mbid: String,
|
||||||
|
name: String,
|
||||||
|
desiredSnoozed: Boolean,
|
||||||
|
): Long = insertUserDriven(
|
||||||
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||||
|
json.encodeToString(
|
||||||
|
SuggestionSnoozeTogglePayload.serializer(),
|
||||||
|
SuggestionSnoozeTogglePayload(mbid, name, desiredSnoozed),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||||
MutationKind.REQUEST_CANCEL,
|
MutationKind.REQUEST_CANCEL,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
@@ -192,6 +217,21 @@ class MutationQueue @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
|
||||||
|
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
|
||||||
|
* the replayer can collapse repeated toggles for one candidate down to the
|
||||||
|
* last intent. Both directions are idempotent server-side: re-snoozing
|
||||||
|
* extends the window, and un-snoozing something already back is a 404 the
|
||||||
|
* replayer treats as permanent (nothing left to do).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SuggestionSnoozeTogglePayload(
|
||||||
|
val mbid: String,
|
||||||
|
val name: String,
|
||||||
|
val desiredSnoozed: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
||||||
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
||||||
@@ -239,6 +279,9 @@ data class PlayOfflinePayload(
|
|||||||
val atIso: String,
|
val atIso: String,
|
||||||
val durationPlayedMs: Long,
|
val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning; null on payloads queued
|
||||||
|
// before this field existed (decodes to null → server stores NULL).
|
||||||
|
val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||||
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
@@ -114,11 +115,12 @@ class MutationReplayer @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun drain() {
|
private suspend fun drain() {
|
||||||
val rows = dao.getAll()
|
val rows = dao.getAll()
|
||||||
// Collapse superseded like-toggles: only the latest desired state per
|
// Collapse superseded toggles (likes, suggestion snoozes): only the
|
||||||
// (entity) is replayed; older toggles for the same entity are dropped
|
// latest desired state per entity is replayed; older toggles for the
|
||||||
// unsent. Without this, partial-failure + differential retry could
|
// same entity are dropped unsent. Without this, partial-failure +
|
||||||
// replay an older toggle last and invert the final like state.
|
// differential retry could replay an older toggle last and invert the
|
||||||
val superseded = supersededLikeToggleIds(rows)
|
// final state — a snooze the user already undid would come back.
|
||||||
|
val superseded = supersededToggleIds(rows, json)
|
||||||
for (row in rows) {
|
for (row in rows) {
|
||||||
if (row.id in superseded) {
|
if (row.id in superseded) {
|
||||||
dao.delete(row.id)
|
dao.delete(row.id)
|
||||||
@@ -131,25 +133,6 @@ class MutationReplayer @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
|
|
||||||
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
|
|
||||||
val latestByEntity = HashMap<String, Long>()
|
|
||||||
val superseded = HashSet<Long>()
|
|
||||||
rows.asSequence()
|
|
||||||
.filter { it.kind == MutationKind.LIKE_TOGGLE }
|
|
||||||
.forEach { row ->
|
|
||||||
val decoded = runCatching {
|
|
||||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
|
||||||
}.getOrNull()
|
|
||||||
if (decoded != null) {
|
|
||||||
val key = "${decoded.entityType}:${decoded.entityId}"
|
|
||||||
// `rows` is ascending by id, so a prior entry is always older.
|
|
||||||
latestByEntity.put(key, row.id)?.let(superseded::add)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return superseded
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
||||||
dispatch(row)
|
dispatch(row)
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
@@ -182,6 +165,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
else -> Outcome.DROP
|
else -> Outcome.DROP
|
||||||
}
|
}
|
||||||
@@ -254,6 +238,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
at = decoded.atIso,
|
at = decoded.atIso,
|
||||||
durationPlayedMs = decoded.durationPlayedMs,
|
durationPlayedMs = decoded.durationPlayedMs,
|
||||||
source = decoded.source,
|
source = decoded.source,
|
||||||
|
deviceClass = decoded.deviceClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
@@ -276,6 +261,24 @@ class MutationReplayer @Inject constructor(
|
|||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replays a suggestion snooze in whichever direction the payload asks for.
|
||||||
|
*
|
||||||
|
* The un-snooze branch can legitimately 404 (the row already lapsed, or a
|
||||||
|
* previous attempt landed and the response was lost). [outcomeFor] classes
|
||||||
|
* 404 as permanent → DROP, which is right: the user's intended end state
|
||||||
|
* already holds, so there is nothing left to send.
|
||||||
|
*/
|
||||||
|
private suspend fun dispatchSuggestionSnoozeToggle(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), payload)
|
||||||
|
if (decoded.desiredSnoozed) {
|
||||||
|
discoverApi.snoozeSuggestion(decoded.mbid, SnoozeSuggestionBody(name = decoded.name))
|
||||||
|
} else {
|
||||||
|
discoverApi.unsnoozeSuggestion(decoded.mbid)
|
||||||
|
}
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
@@ -296,3 +299,46 @@ class MutationReplayer @Inject constructor(
|
|||||||
const val HTTP_TOO_MANY = 429
|
const val HTTP_TOO_MANY = 429
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||||
|
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||||
|
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
||||||
|
* replaying a stale one last would invert the final state.
|
||||||
|
*
|
||||||
|
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||||
|
* instance. [rows] must be ascending by id (FIFO), which is what
|
||||||
|
* `CachedMutationDao.getAll()` returns.
|
||||||
|
*/
|
||||||
|
internal fun supersededToggleIds(rows: List<CachedMutationEntity>, json: Json): Set<Long> {
|
||||||
|
val latestByEntity = HashMap<String, Long>()
|
||||||
|
val superseded = HashSet<Long>()
|
||||||
|
rows.asSequence()
|
||||||
|
.mapNotNull { row -> toggleKeyOf(row, json)?.let { key -> key to row.id } }
|
||||||
|
.forEach { (key, id) ->
|
||||||
|
// Ascending ids mean a prior entry for this key is always older.
|
||||||
|
latestByEntity.put(key, id)?.let(superseded::add)
|
||||||
|
}
|
||||||
|
return superseded
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse key for a toggle row, or null when the row isn't a toggle — or its
|
||||||
|
* payload won't decode. Undecodable rows are deliberately left alone rather
|
||||||
|
* than grouped under a shared "corrupt" key, so one bad row can't suppress a
|
||||||
|
* good one behind it; the dispatcher DROPs it on its own.
|
||||||
|
*
|
||||||
|
* The kind is part of the key so two toggle kinds can never collide on the
|
||||||
|
* same entity id.
|
||||||
|
*/
|
||||||
|
private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (row.kind) {
|
||||||
|
MutationKind.LIKE_TOGGLE -> runCatching {
|
||||||
|
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { "${row.kind}:${it.entityType}:${it.entityId}" }
|
||||||
|
|
||||||
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> runCatching {
|
||||||
|
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.fabledsword.minstrel.connectivity
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
import com.fabledsword.minstrel.BuildConfig
|
import com.fabledsword.minstrel.BuildConfig
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
@@ -41,6 +44,12 @@ private const val ARBITRATE_MIN_GAP_MS = 2_000L
|
|||||||
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
* - reportSuccess / reportFailure from the API interceptor, the audio data
|
||||||
* source, and the playback-error reporter.
|
* source, and the playback-error reporter.
|
||||||
* - recheck() from pull-to-refresh and the banner.
|
* - recheck() from pull-to-refresh and the banner.
|
||||||
|
* - a forced probe when the app returns to the foreground (#1209). Without
|
||||||
|
* it a stale ServerDown outlived the condition that caused it: the poll
|
||||||
|
* loop's delay() is throttled while screen-off/doze, so recovery waited on
|
||||||
|
* whenever the OS next let the loop run. Meanwhile ServerDown makes
|
||||||
|
* OfflineGatedDataSource refuse every uncached track, so the app declined
|
||||||
|
* to play music that would have played fine.
|
||||||
*
|
*
|
||||||
* Version compatibility is a byproduct of the same /healthz response.
|
* Version compatibility is a byproduct of the same /healthz response.
|
||||||
*
|
*
|
||||||
@@ -53,7 +62,7 @@ class NetworkStatusController @Inject constructor(
|
|||||||
connectivity: ConnectivityObserver,
|
connectivity: ConnectivityObserver,
|
||||||
private val authStore: AuthStore,
|
private val authStore: AuthStore,
|
||||||
retrofit: Retrofit,
|
retrofit: Retrofit,
|
||||||
) {
|
) : DefaultLifecycleObserver {
|
||||||
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
|
||||||
private val machine = ReachabilityMachine()
|
private val machine = ReachabilityMachine()
|
||||||
private val lastProbeAtMs = AtomicLong(0)
|
private val lastProbeAtMs = AtomicLong(0)
|
||||||
@@ -74,6 +83,7 @@ class NetworkStatusController @Inject constructor(
|
|||||||
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
private val intents = Channel<Intent>(Channel.UNLIMITED)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
|
||||||
scope.launch { reduceLoop() }
|
scope.launch { reduceLoop() }
|
||||||
scope.launch {
|
scope.launch {
|
||||||
connectivity.online.collect { up ->
|
connectivity.online.collect { up ->
|
||||||
@@ -100,6 +110,20 @@ class NetworkStatusController @Inject constructor(
|
|||||||
scope.launch { probeOnce(force = true) }
|
scope.launch { probeOnce(force = true) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App returned to the foreground — probe now rather than waiting for the
|
||||||
|
* poll loop (#1209).
|
||||||
|
*
|
||||||
|
* The link-return probe in `init` does NOT cover this: it fires on a
|
||||||
|
* connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
|
||||||
|
* force = true so this also bypasses the ARBITRATE_MIN_GAP_MS throttle —
|
||||||
|
* a user bringing the app up is exactly when a stale banner and a refused
|
||||||
|
* track are most visible, and it's a once-per-foreground cost.
|
||||||
|
*/
|
||||||
|
override fun onStart(owner: LifecycleOwner) {
|
||||||
|
recheck()
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun reduceLoop() {
|
private suspend fun reduceLoop() {
|
||||||
for (intent in intents) {
|
for (intent in intents) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|||||||
@@ -4,6 +4,24 @@ internal const val ESCALATE_AFTER_MS = 120_000L
|
|||||||
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
internal const val CORROBORATION_WINDOW_MS = 30_000L
|
||||||
internal const val CORROBORATION_OP_THRESHOLD = 2
|
internal const val CORROBORATION_OP_THRESHOLD = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum gap between op failures for them to count as SEPARATE evidence
|
||||||
|
* (#1209).
|
||||||
|
*
|
||||||
|
* A link handoff fails every in-flight request at once, so a burst is one
|
||||||
|
* event producing N failures — not N independent observations that the server
|
||||||
|
* is gone. Without this, two simultaneous failures corroborated each other
|
||||||
|
* straight to Unreachable, and ServerDown makes OfflineGatedDataSource refuse
|
||||||
|
* every uncached track. The app declined to play music that would have played
|
||||||
|
* fine, for a blip that had already resolved.
|
||||||
|
*
|
||||||
|
* 3s is comfortably above the sub-second window an OS handoff occupies while
|
||||||
|
* still letting a genuine outage corroborate within seconds once a client
|
||||||
|
* retries. The sustained-time backstop covers the case where nothing retries
|
||||||
|
* at all — and if nothing is asking, a late ServerDown costs nothing.
|
||||||
|
*/
|
||||||
|
internal const val CORROBORATION_MIN_SPACING_MS = 3_000L
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
* Pure reachability state machine. No Android, no coroutines, no real clock —
|
||||||
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
* every entry point takes `nowMs`, so it is fully deterministic and unit-
|
||||||
@@ -46,9 +64,17 @@ class ReachabilityMachine {
|
|||||||
recentOpFailures.clear()
|
recentOpFailures.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A real network op failed. Ambiguous on its own — records corroboration. */
|
/**
|
||||||
|
* A real network op failed. Ambiguous on its own — records corroboration.
|
||||||
|
*
|
||||||
|
* Failures arriving within [CORROBORATION_MIN_SPACING_MS] of the last
|
||||||
|
* recorded one are dropped rather than stacked: see that constant for why
|
||||||
|
* a burst must not corroborate itself.
|
||||||
|
*/
|
||||||
fun onOpFailure(nowMs: Long) {
|
fun onOpFailure(nowMs: Long) {
|
||||||
pruneOpFailures(nowMs)
|
pruneOpFailures(nowMs)
|
||||||
|
val last = recentOpFailures.lastOrNull()
|
||||||
|
if (last != null && nowMs - last < CORROBORATION_MIN_SPACING_MS) return
|
||||||
recentOpFailures.addLast(nowMs)
|
recentOpFailures.addLast(nowMs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,14 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
|||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.models.SeedContributionRef
|
import com.fabledsword.minstrel.models.SeedContributionRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
|
import retrofit2.HttpException
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -46,6 +50,69 @@ class DiscoverRepository @Inject constructor(
|
|||||||
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
||||||
api.listSuggestions().map { it.toDomain() }
|
api.listSuggestions().map { it.toDomain() }
|
||||||
|
|
||||||
|
suspend fun listSnoozes(): List<SuggestionSnoozeRef> =
|
||||||
|
api.listSnoozes().map { it.toDomain() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parks a suggestion ("not right now"). Offline-first per rule #100: on
|
||||||
|
* transport failure the target state is queued for the replayer rather
|
||||||
|
* than dropped.
|
||||||
|
*
|
||||||
|
* Always reports success to the caller. Unlike a request, a snooze has no
|
||||||
|
* meaningful failed state to show — the user asked for a card to go away,
|
||||||
|
* and it will, either now or when the queue drains.
|
||||||
|
*/
|
||||||
|
suspend fun snoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
desiredSnoozed = true,
|
||||||
|
) { api.snoozeSuggestion(mbid, SnoozeSuggestionBody(name = name)) }
|
||||||
|
|
||||||
|
/** Brings a parked suggestion back. Same offline-first contract. */
|
||||||
|
suspend fun unsnoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
desiredSnoozed = false,
|
||||||
|
) { api.unsnoozeSuggestion(mbid) }
|
||||||
|
|
||||||
|
private suspend fun toggleSnooze(
|
||||||
|
mbid: String,
|
||||||
|
name: String,
|
||||||
|
desiredSnoozed: Boolean,
|
||||||
|
call: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
call()
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
// A 4xx is the server's considered answer, not a lost call, so
|
||||||
|
// queueing it would be wrong twice over: the replay is guaranteed
|
||||||
|
// to fail again, and the enqueue would raise a "will sync when
|
||||||
|
// online" snackbar for something already settled. The common case
|
||||||
|
// is a 404 from un-snoozing a row that already lapsed — which is
|
||||||
|
// the end state the user wanted anyway.
|
||||||
|
if (!isPermanent(e.code())) {
|
||||||
|
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Transport failure — intentional swallow, same offline-first
|
||||||
|
// rationale as createRequest above. The queue carries the desired
|
||||||
|
// STATE, so a later undo supersedes this rather than fighting it
|
||||||
|
// on replay.
|
||||||
|
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors MutationReplayer's classification so the enqueue decision here
|
||||||
|
* and the drop decision there can't disagree: 4xx is permanent except the
|
||||||
|
* two "retry me" statuses.
|
||||||
|
*/
|
||||||
|
private fun isPermanent(code: Int): Boolean =
|
||||||
|
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||||
|
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
||||||
|
|
||||||
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
||||||
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||||
|
|
||||||
@@ -85,6 +152,13 @@ class DiscoverRepository @Inject constructor(
|
|||||||
RequestOutcome.QUEUED
|
RequestOutcome.QUEUED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val HTTP_CLIENT_ERR_MIN = 400
|
||||||
|
const val HTTP_CLIENT_ERR_MAX = 499
|
||||||
|
const val HTTP_TIMEOUT = 408
|
||||||
|
const val HTTP_TOO_MANY = 429
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mappers (internal — wire types stay out of UI) ──
|
// ── Mappers (internal — wire types stay out of UI) ──
|
||||||
@@ -105,6 +179,7 @@ private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggest
|
|||||||
name = name,
|
name = name,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
attribution = attribution.map { it.toDomain() },
|
attribution = attribution.map { it.toDomain() },
|
||||||
|
matchedTags = matchedTags,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
||||||
@@ -112,6 +187,12 @@ private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContribut
|
|||||||
isLiked = isLiked,
|
isLiked = isLiked,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
snoozedUntil = snoozedUntil,
|
||||||
|
)
|
||||||
|
|
||||||
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
artistMbid = artistMbid,
|
artistMbid = artistMbid,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
|||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -104,6 +105,18 @@ private fun DiscoverBody(
|
|||||||
ResultsState.Idle -> SuggestionsPane(
|
ResultsState.Idle -> SuggestionsPane(
|
||||||
state = state.suggestions,
|
state = state.suggestions,
|
||||||
locallyRequestedMbids = state.locallyRequestedMbids,
|
locallyRequestedMbids = state.locallyRequestedMbids,
|
||||||
|
snoozeUi = SnoozeUi(
|
||||||
|
locallySnoozedMbids = state.locallySnoozedMbids,
|
||||||
|
snoozes = state.snoozes,
|
||||||
|
// No snackbar on snooze: the row itself flips to "Not
|
||||||
|
// right now" with an Undo, so a snackbar would only
|
||||||
|
// repeat what the user can already see — and cover the
|
||||||
|
// next row while doing it.
|
||||||
|
onSnooze = { s -> scope.launch { viewModel.snoozeSuggestion(s) } },
|
||||||
|
onUnsnooze = { mbid, name ->
|
||||||
|
scope.launch { viewModel.unsnoozeSuggestion(mbid, name) }
|
||||||
|
},
|
||||||
|
),
|
||||||
onRequest = { s ->
|
onRequest = { s ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val outcome = viewModel.requestSuggestion(s)
|
val outcome = viewModel.requestSuggestion(s)
|
||||||
@@ -178,10 +191,23 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The snooze surface's data and callbacks, bundled rather than threaded
|
||||||
|
* through as four more parameters — the pane grew from one action to three
|
||||||
|
* with slice 4 and the signatures stopped being readable.
|
||||||
|
*/
|
||||||
|
private data class SnoozeUi(
|
||||||
|
val locallySnoozedMbids: Set<String>,
|
||||||
|
val snoozes: List<SuggestionSnoozeRef>,
|
||||||
|
val onSnooze: (ArtistSuggestionRef) -> Unit,
|
||||||
|
val onUnsnooze: (String, String) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionsPane(
|
private fun SuggestionsPane(
|
||||||
state: SuggestionState,
|
state: SuggestionState,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
|
snoozeUi: SnoozeUi,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -194,6 +220,7 @@ private fun SuggestionsPane(
|
|||||||
)
|
)
|
||||||
is SuggestionState.Loaded -> SuggestionsList(
|
is SuggestionState.Loaded -> SuggestionsList(
|
||||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||||
|
snoozeUi = snoozeUi,
|
||||||
onRequest = onRequest,
|
onRequest = onRequest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -202,6 +229,7 @@ private fun SuggestionsPane(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionsList(
|
private fun SuggestionsList(
|
||||||
items: List<ArtistSuggestionRef>,
|
items: List<ArtistSuggestionRef>,
|
||||||
|
snoozeUi: SnoozeUi,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -210,13 +238,61 @@ private fun SuggestionsList(
|
|||||||
) {
|
) {
|
||||||
item { SuggestionsHeader() }
|
item { SuggestionsHeader() }
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
item { CenteredMessage("Listen to or like an artist to fill this in.") }
|
// An empty deck used to mean one thing — no listening signal yet.
|
||||||
|
// With snoozing it can also mean "you parked them all", and telling
|
||||||
|
// that user to go listen to something would be wrong advice.
|
||||||
|
item {
|
||||||
|
CenteredMessage(
|
||||||
|
if (snoozeUi.snoozes.isEmpty()) {
|
||||||
|
"Listen to or like an artist to fill this in."
|
||||||
|
} else {
|
||||||
|
"Nothing new right now — the artists you've parked are below."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
items(items = items, key = { it.mbid }) { s ->
|
items(items = items, key = { it.mbid }) { s ->
|
||||||
SuggestionTile(s = s, onRequest = { onRequest(s) })
|
SuggestionTile(
|
||||||
|
s = s,
|
||||||
|
snoozed = s.mbid in snoozeUi.locallySnoozedMbids,
|
||||||
|
onRequest = { onRequest(s) },
|
||||||
|
onSnooze = { snoozeUi.onSnooze(s) },
|
||||||
|
onUnsnooze = { snoozeUi.onUnsnooze(s.mbid, s.name) },
|
||||||
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Parked candidates live at the bottom of the same scroll, not behind a
|
||||||
|
// separate screen: it's a short list the user rarely needs, but it must
|
||||||
|
// be reachable — a snoozed candidate is gone from the deck above, so
|
||||||
|
// this is the only way back to it.
|
||||||
|
if (snoozeUi.snoozes.isNotEmpty()) {
|
||||||
|
item { SnoozedHeader() }
|
||||||
|
items(items = snoozeUi.snoozes, key = { "snoozed-${it.mbid}" }) { row ->
|
||||||
|
SnoozedTile(
|
||||||
|
row = row,
|
||||||
|
onUnsnooze = { snoozeUi.onUnsnooze(row.mbid, row.name) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SnoozedHeader() {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Not right now",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "These come back on their own. Nothing here counts against your taste profile.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -24,14 +26,22 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.composables.icons.lucide.Clock
|
||||||
import com.composables.icons.lucide.Disc3
|
import com.composables.icons.lucide.Disc3
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
internal fun SuggestionTile(
|
||||||
|
s: ArtistSuggestionRef,
|
||||||
|
snoozed: Boolean,
|
||||||
|
onRequest: () -> Unit,
|
||||||
|
onSnooze: () -> Unit,
|
||||||
|
onUnsnooze: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -48,9 +58,13 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (s.attributionText.isNotEmpty()) {
|
// Once parked, the "because you liked X" line is no longer the
|
||||||
|
// useful thing to say — confirming what just happened is.
|
||||||
|
val secondary =
|
||||||
|
if (snoozed) "Not right now — hidden for a while" else s.reasonText
|
||||||
|
if (secondary.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = s.attributionText,
|
text = secondary,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
@@ -58,7 +72,52 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button(onClick = onRequest) { Text("Request") }
|
if (snoozed) {
|
||||||
|
TextButton(onClick = onUnsnooze) { Text("Undo") }
|
||||||
|
} else {
|
||||||
|
Button(onClick = onRequest) { Text("Request") }
|
||||||
|
IconButton(onClick = onSnooze) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.Clock,
|
||||||
|
// Rule #101: the label states what happens, and passes no
|
||||||
|
// judgement on the music. Never "not for me".
|
||||||
|
contentDescription = "Not right now — hide ${s.name} for a while",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the parked list. This exists because a snoozed candidate is by
|
||||||
|
* definition absent from the deck above, so without it there is no route back
|
||||||
|
* to an un-snooze once the card has gone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = row.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Back ${row.returnsIn()}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onUnsnooze) { Text("Bring back") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
|||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -27,6 +28,17 @@ data class DiscoverState(
|
|||||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||||
val results: ResultsState = ResultsState.Idle,
|
val results: ResultsState = ResultsState.Idle,
|
||||||
val locallyRequestedMbids: Set<String> = emptySet(),
|
val locallyRequestedMbids: Set<String> = emptySet(),
|
||||||
|
/**
|
||||||
|
* Parked candidates, for the manage list under the feed. Empty is the
|
||||||
|
* normal case and hides the section entirely.
|
||||||
|
*/
|
||||||
|
val snoozes: List<SuggestionSnoozeRef> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Just-snoozed MBIDs. These keep their row visible showing an Undo rather
|
||||||
|
* than yanking it out from under the user's finger; the row is gone on the
|
||||||
|
* next load, and [snoozes] is the way back after that.
|
||||||
|
*/
|
||||||
|
val locallySnoozedMbids: Set<String> = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SuggestionState {
|
sealed interface SuggestionState {
|
||||||
@@ -96,6 +108,47 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Refresh the parked list alongside the deck: a snooze made on another
|
||||||
|
// client should show up here, and one whose window lapsed should drop
|
||||||
|
// off. Sequenced after the deck load rather than raced with it so the
|
||||||
|
// two panes can't disagree about a candidate mid-refresh.
|
||||||
|
loadSnoozes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the parked list. Failure is deliberately silent: this is a
|
||||||
|
* secondary pane, and an error banner for it would sit above the suggestion
|
||||||
|
* feed the user actually came for. The list stays as-is and the next
|
||||||
|
* refresh retries.
|
||||||
|
*/
|
||||||
|
private suspend fun loadSnoozes() {
|
||||||
|
try {
|
||||||
|
val rows = repository.listSnoozes()
|
||||||
|
internal.update { it.copy(snoozes = rows) }
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Keep whatever we last showed rather than blanking the section.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parks a suggestion. Flips the row locally first so the tap registers
|
||||||
|
* immediately; the repository handles the offline case, so there is no
|
||||||
|
* failure branch to revert here — unlike the web client, where the fetch
|
||||||
|
* either lands or doesn't.
|
||||||
|
*/
|
||||||
|
suspend fun snoozeSuggestion(s: ArtistSuggestionRef) {
|
||||||
|
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids + s.mbid) }
|
||||||
|
repository.snoozeSuggestion(s.mbid, s.name)
|
||||||
|
loadSnoozes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brings a parked suggestion back, from either the card or the list. */
|
||||||
|
suspend fun unsnoozeSuggestion(mbid: String, name: String) {
|
||||||
|
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids - mbid) }
|
||||||
|
repository.unsnoozeSuggestion(mbid, name)
|
||||||
|
loadSnoozes()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun runSearch() {
|
fun runSearch() {
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import com.fabledsword.minstrel.models.TrackRef
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -34,9 +36,10 @@ import javax.inject.Singleton
|
|||||||
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
||||||
* per-item tile providers.
|
* per-item tile providers.
|
||||||
*
|
*
|
||||||
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section
|
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
|
||||||
* in-place (delete-then-insert, so the section Flows re-fire), and
|
* one transaction (so the rows update together in a single emission
|
||||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
* rather than collapsing and refilling), and pre-warms the top artists
|
||||||
|
* via [HomeArtistPrewarmer].
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
// Per-section observe accessors (one per Home row) inflate the function
|
// Per-section observe accessors (one per Home row) inflate the function
|
||||||
@@ -85,72 +88,98 @@ class HomeRepository @Inject constructor(
|
|||||||
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
||||||
|
|
||||||
|
/** True when the index cache already has content on screen to protect. */
|
||||||
|
suspend fun hasCachedIndex(): Boolean = homeIndexDao.hasAny()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls /api/home/index, replaces each cached_home_index section,
|
* Pulls /api/home/index and swaps every cached_home_index section in
|
||||||
* and pre-warms the top artists. The section Flows re-fire on the
|
* a single transaction, then pre-warms the top artists. Missing
|
||||||
* index change; missing entity rows hydrate via the on-miss path.
|
* entity rows hydrate via the on-miss path.
|
||||||
|
*
|
||||||
|
* One transaction for all seven sections is deliberate: Room notifies
|
||||||
|
* observers once, on commit, so Home swaps from the old content to
|
||||||
|
* the new in a single emission. Per-section, un-transacted writes
|
||||||
|
* made every row visibly collapse to empty and refill, one after
|
||||||
|
* another (issue #2327).
|
||||||
*/
|
*/
|
||||||
suspend fun refreshIndex() {
|
suspend fun refreshIndex() {
|
||||||
val wire = api.getHomeIndex()
|
val wire = api.getHomeIndex()
|
||||||
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums)
|
homeIndexDao.replaceSections(
|
||||||
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums)
|
sections = ALL_SECTIONS,
|
||||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
rows = rowsFor(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) +
|
||||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
rowsFor(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) +
|
||||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
rowsFor(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) +
|
||||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
|
rowsFor(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) +
|
||||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
|
rowsFor(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) +
|
||||||
|
rowsFor(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums) +
|
||||||
|
rowsFor(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists),
|
||||||
|
)
|
||||||
prewarmer.warm(
|
prewarmer.warm(
|
||||||
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
private fun rowsFor(
|
||||||
homeIndexDao.deleteBySection(section)
|
section: String,
|
||||||
if (ids.isEmpty()) return
|
entityType: String,
|
||||||
homeIndexDao.upsertAll(
|
ids: List<String>,
|
||||||
ids.mapIndexed { index, id ->
|
): List<CachedHomeIndexEntity> = ids.mapIndexed { index, id ->
|
||||||
CachedHomeIndexEntity(
|
CachedHomeIndexEntity(
|
||||||
section = section,
|
section = section,
|
||||||
position = index,
|
position = index,
|
||||||
entityType = entityType,
|
entityType = entityType,
|
||||||
entityId = id,
|
entityId = id,
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The section's ordered entity ids, deduplicated.
|
||||||
|
*
|
||||||
|
* Room re-runs the query on every write to `cached_home_index` — and
|
||||||
|
* `CachedHomeIndexEntity.fetchedAt` is stamped fresh each time — so
|
||||||
|
* comparing whole rows would call every rewrite a change. Comparing
|
||||||
|
* the id list instead means a section whose contents didn't actually
|
||||||
|
* move never restarts the `flatMapLatest` below, which would
|
||||||
|
* otherwise tear down and rebuild all of its tiles' hydration flows
|
||||||
|
* and flicker unchanged tiles (issue #2327).
|
||||||
|
*/
|
||||||
|
private fun observeSectionIds(section: String): Flow<List<String>> =
|
||||||
|
homeIndexDao.observeBySection(section)
|
||||||
|
.map { rows -> rows.map { it.entityId } }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeAlbum(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeArtist(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeTrack(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,5 +192,16 @@ class HomeRepository @Inject constructor(
|
|||||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
||||||
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
||||||
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
||||||
|
|
||||||
|
/** Every section [refreshIndex] owns — the unit of one atomic swap. */
|
||||||
|
val ALL_SECTIONS = listOf(
|
||||||
|
SECTION_RECENTLY_ADDED_ALBUMS,
|
||||||
|
SECTION_REDISCOVER_ALBUMS,
|
||||||
|
SECTION_REDISCOVER_ARTISTS,
|
||||||
|
SECTION_MOST_PLAYED_TRACKS,
|
||||||
|
SECTION_LAST_PLAYED_ARTISTS,
|
||||||
|
SECTION_YOU_MIGHT_LIKE_ALBUMS,
|
||||||
|
SECTION_YOU_MIGHT_LIKE_ARTISTS,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
package com.fabledsword.minstrel.home.ui
|
package com.fabledsword.minstrel.home.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -27,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -34,6 +43,7 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -76,29 +86,38 @@ import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
|
|||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
|
import com.fabledsword.minstrel.shared.UpdateVeilController
|
||||||
|
import com.fabledsword.minstrel.shared.VeilOutcome
|
||||||
|
import com.fabledsword.minstrel.shared.VeilSessionResult
|
||||||
|
import com.fabledsword.minstrel.shared.VeilSettleState
|
||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LocalArtSettleTracker
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
@@ -110,6 +129,22 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
|||||||
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
||||||
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
||||||
|
|
||||||
|
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
|
||||||
|
// appears at all — only when a refresh actually changes something — and how
|
||||||
|
// long it stays, by watching the screen settle rather than by a fixed delay,
|
||||||
|
// which lowered it while tiles and artwork were still landing (#2327).
|
||||||
|
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
||||||
|
private const val VEIL_WIPE_MS = 280
|
||||||
|
private const val VEIL_ALPHA = 0.96f
|
||||||
|
private const val VEIL_SPINNER_DP = 22
|
||||||
|
private const val VEIL_SPINNER_STROKE_DP = 2
|
||||||
|
private const val VEIL_LABEL_GAP_DP = 12
|
||||||
|
|
||||||
|
// Backstop on how long a manual pull keeps its own indicator while waiting
|
||||||
|
// for its successor — the veil, or the "already up to date" snackbar — so the
|
||||||
|
// two never both vanish for a frame mid-handoff.
|
||||||
|
private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class HomeSections(
|
data class HomeSections(
|
||||||
@@ -164,10 +199,13 @@ class HomeViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
private val snackbarMessages = Channel<String>(Channel.BUFFERED)
|
||||||
|
|
||||||
/** Transient snackbar messages from offline-pool taps. */
|
/**
|
||||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
* Transient snackbar messages: offline-pool taps, playback failures, and
|
||||||
|
* the outcome of a refresh the user explicitly asked for.
|
||||||
|
*/
|
||||||
|
val transientMessages: Flow<String> = snackbarMessages.receiveAsFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy for the most recent /home/index refresh failure; null once a
|
* Copy for the most recent /home/index refresh failure; null once a
|
||||||
@@ -177,24 +215,13 @@ class HomeViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
/**
|
||||||
refresh()
|
* Cover-art loads in flight on Home, reported by every [ServerImage]
|
||||||
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
* under [LocalArtSettleTracker]. The veil waits on this so artwork
|
||||||
// load while the server was unreachable re-pulls itself the moment
|
* arriving a beat after its tile lands behind the veil rather than
|
||||||
// health returns — same idiom as SyncController, one layer up.
|
* popping in on screen.
|
||||||
viewModelScope.launch {
|
*/
|
||||||
networkStatus.recoveries().collect { refresh() }
|
val artTracker = ArtSettleTracker()
|
||||||
}
|
|
||||||
// #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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
||||||
@@ -207,7 +234,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
||||||
}.shuffled()
|
}.shuffled()
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
poolMessages.trySend("No cached ${kind.label} tracks yet")
|
snackbarMessages.trySend("No cached ${kind.label} tracks yet")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
||||||
}
|
}
|
||||||
@@ -244,14 +271,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
||||||
if (detail.tracks.isEmpty()) {
|
if (detail.tracks.isEmpty()) {
|
||||||
poolMessages.trySend("This album has no tracks to play.")
|
snackbarMessages.trySend("This album has no tracks to play.")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
poolMessages.trySend(
|
snackbarMessages.trySend(
|
||||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -269,14 +296,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
poolMessages.trySend("This artist has no tracks to play.")
|
snackbarMessages.trySend("This artist has no tracks to play.")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
poolMessages.trySend(
|
snackbarMessages.trySend(
|
||||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -294,33 +321,67 @@ class HomeViewModel @Inject constructor(
|
|||||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
||||||
poolMessages.trySend(it)
|
snackbarMessages.trySend(it)
|
||||||
}
|
}
|
||||||
}.join()
|
}.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls both /home/index and the playlists list. Returns the Job
|
* Pulls /home/index, the playlists list and the system-playlist
|
||||||
* for the combined refresh so a pull-to-refresh wrapper can await
|
* status. Returns true when the load-bearing /home/index pull
|
||||||
* actual completion before hiding the indicator.
|
* succeeded — the veil controller retries on false and reports the
|
||||||
|
* outcome, so this must report failure rather than swallow it.
|
||||||
*/
|
*/
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
private suspend fun runRefresh(): Boolean = coroutineScope {
|
||||||
refreshError.value = null
|
// /home/index is the load-bearing pull: its failure drives the
|
||||||
val home = launch {
|
// empty-cache Error state. A failure over a populated cache
|
||||||
// /home/index is the load-bearing pull: its failure drives the
|
// stays silent — cached sections beat a full-screen error.
|
||||||
// empty-cache Error state. A failure over a populated cache
|
//
|
||||||
// stays silent — cached sections beat a full-screen error.
|
// Cleared on success, NOT at the start of each attempt: with the
|
||||||
|
// veil's retries, clearing up front made a failing cold start
|
||||||
|
// flash the "Welcome to Minstrel" empty state (empty cache + no
|
||||||
|
// error reads as Empty) between one attempt and the next.
|
||||||
|
val home = async {
|
||||||
runCatching { homeRepository.refreshIndex() }
|
runCatching { homeRepository.refreshIndex() }
|
||||||
|
.onSuccess { refreshError.value = null }
|
||||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
|
.isSuccess
|
||||||
}
|
}
|
||||||
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||||
val status = launch {
|
val status = launch {
|
||||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||||
.onSuccess { systemStatusInternal.value = it }
|
.onSuccess { systemStatusInternal.value = it }
|
||||||
}
|
}
|
||||||
home.join()
|
|
||||||
lists.join()
|
lists.join()
|
||||||
status.join()
|
status.join()
|
||||||
|
home.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Error state's explicit Retry button. User-initiated, so it gets
|
||||||
|
* the controller's retries and reports its outcome; over an empty cache
|
||||||
|
* there's no content to protect, so no veil goes up.
|
||||||
|
*/
|
||||||
|
fun retry() = veil.request(userInitiated = true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual pull-to-refresh. Goes behind the veil like every other refresh
|
||||||
|
* (operator call, 2026-07-31: the churn a pull causes is identical to
|
||||||
|
* the automatic paths, and a small spinner didn't hide it).
|
||||||
|
*
|
||||||
|
* Suspends until the veil has taken over OR the session has finished,
|
||||||
|
* so the pull indicator hands off to exactly one successor: the veil if
|
||||||
|
* content changed, the "Already up to date" snackbar if it didn't. The
|
||||||
|
* timeout is only a backstop against a session that outlives it.
|
||||||
|
*/
|
||||||
|
suspend fun refreshFromPull() {
|
||||||
|
val before = veil.finishedSessions.value
|
||||||
|
veil.request(userInitiated = true)
|
||||||
|
withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) {
|
||||||
|
combine(veil.visible, veil.finishedSessions) { veiled, finished ->
|
||||||
|
veiled || finished != before
|
||||||
|
}.first { it }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<HomeSections>> =
|
val uiState: StateFlow<UiState<HomeSections>> =
|
||||||
@@ -371,6 +432,124 @@ class HomeViewModel @Inject constructor(
|
|||||||
else -> UiState.Empty
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Updating veil ───────────────────────────────────────────────
|
||||||
|
// Declared after uiState: these initialisers read it, and Kotlin runs
|
||||||
|
// property initialisers and init blocks in declaration order.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the veil watches to decide Home has stopped moving: the whole
|
||||||
|
* rendered state, plus how many covers are still loading.
|
||||||
|
*
|
||||||
|
* [UiState.Success] wraps a [HomeSections] data class, so any visible
|
||||||
|
* change — a section swapping ids, one tile hydrating from skeleton to
|
||||||
|
* album — changes this value and re-arms the veil's quiet window.
|
||||||
|
*
|
||||||
|
* Unhydrated tiles deliberately do NOT gate `quiescent`. A tile whose
|
||||||
|
* on-miss fetch soft-fails keeps a null value indefinitely
|
||||||
|
* ([MetadataProvider] swallows those errors), so treating "no
|
||||||
|
* skeletons left" as the settle condition would pin the veil to its
|
||||||
|
* hard ceiling on every refresh. They're covered by the content key
|
||||||
|
* instead: each tile that lands re-arms the window, and once they stop
|
||||||
|
* landing the screen is genuinely still.
|
||||||
|
*/
|
||||||
|
private val settleSignal: Flow<VeilSettleState> =
|
||||||
|
combine(uiState, artTracker.inFlight) { state, artInFlight ->
|
||||||
|
VeilSettleState(
|
||||||
|
contentKey = state,
|
||||||
|
hasContent = state is UiState.Success,
|
||||||
|
quiescent = artInFlight == 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val veil = UpdateVeilController(
|
||||||
|
scope = viewModelScope,
|
||||||
|
settleSignal = settleSignal,
|
||||||
|
shouldVeil = {
|
||||||
|
// Only worth hiding churn when there's already content to
|
||||||
|
// hide. A cold load over an empty cache keeps its skeleton —
|
||||||
|
// veiling that would replace a useful affordance with an
|
||||||
|
// opaque panel. `hasCachedIndex` is the honest check: uiState
|
||||||
|
// still reads Loading until the screen subscribes, so on a
|
||||||
|
// process restore over a warm cache it would say "no content"
|
||||||
|
// right before the cache emits.
|
||||||
|
uiState.value is UiState.Success || homeRepository.hasCachedIndex()
|
||||||
|
},
|
||||||
|
onSessionEnd = ::reportRefreshOutcome,
|
||||||
|
work = ::runRefresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the user how a refresh *they asked for* went, in the one case
|
||||||
|
* the veil can't: when nothing changed there's no veil to see, and a
|
||||||
|
* pull that produces no visible response at all reads as broken.
|
||||||
|
*
|
||||||
|
* Only user-initiated sessions say anything. The same outcome from a
|
||||||
|
* background check — the initial load, the 03:00 rebuild, a reconnect —
|
||||||
|
* is noise, and "Already up to date" on every launch would be worse
|
||||||
|
* than silence (operator's call, 2026-07-31).
|
||||||
|
*/
|
||||||
|
private fun reportRefreshOutcome(result: VeilSessionResult) {
|
||||||
|
if (!result.userInitiated) return
|
||||||
|
when (result.outcome) {
|
||||||
|
// The veil was the feedback.
|
||||||
|
VeilOutcome.CHANGED -> return
|
||||||
|
VeilOutcome.UNCHANGED -> snackbarMessages.trySend("Already up to date")
|
||||||
|
VeilOutcome.FAILED -> snackbarMessages.trySend("Couldn't check for updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the "Updating your mixes…" veil should be raised.
|
||||||
|
*
|
||||||
|
* Raised only when a refresh actually changes what's on screen, and then
|
||||||
|
* held until Home settles — tiles hydrated, artwork loaded — instead of
|
||||||
|
* for a fixed delay after the network pull returns (issue #2327). A
|
||||||
|
* refresh that returns what's already cached shows no veil at all;
|
||||||
|
* [reportRefreshOutcome] tells the user instead, if they asked.
|
||||||
|
*/
|
||||||
|
val isUpdating: StateFlow<Boolean> = veil.visible
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Every refresh path goes through the controller, which decides
|
||||||
|
// per session whether to raise the veil. That includes the initial
|
||||||
|
// load: over a warm cache it's a full re-pull that churns every
|
||||||
|
// section, and it used to run completely unveiled.
|
||||||
|
veil.request()
|
||||||
|
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
||||||
|
// load while the server was unreachable re-pulls itself the moment
|
||||||
|
// health returns — same idiom as SyncController, one layer up.
|
||||||
|
// This is also the recovery that keeps trying after the veil has
|
||||||
|
// given up and lowered; the controller sets no latch against it.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { veil.request() }
|
||||||
|
}
|
||||||
|
// Server-side changes that rewrite what Home renders (#968 and
|
||||||
|
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the
|
||||||
|
// web SSE consumer.
|
||||||
|
viewModelScope.launch {
|
||||||
|
eventsStream.events
|
||||||
|
.filter { it.kind in VEILED_EVENT_KINDS }
|
||||||
|
.collect { veil.request() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Events that change what Home shows. `playlist.system_rebuilt`
|
||||||
|
* is the 03:00 daily rebuild; the other `playlist.*` kinds move
|
||||||
|
* the Playlists and Songs-like rows; `scan.run_finished` changes
|
||||||
|
* Recently added (and Home never reacted to it at all before).
|
||||||
|
*/
|
||||||
|
private val VEILED_EVENT_KINDS = setOf(
|
||||||
|
"playlist.system_rebuilt",
|
||||||
|
"playlist.created",
|
||||||
|
"playlist.updated",
|
||||||
|
"playlist.deleted",
|
||||||
|
"playlist.tracks_changed",
|
||||||
|
"scan.run_finished",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Screen ──────────────────────────────────────────────────────────
|
// ─── Screen ──────────────────────────────────────────────────────────
|
||||||
@@ -398,47 +577,114 @@ fun HomeScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
|
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refreshFromPull() },
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
// Key Crossfade on the state CLASS, not the instance. Each
|
Box(Modifier.fillMaxSize()) {
|
||||||
// section emission produces a new UiState.Success(data); if
|
// Every cover below reports its load state to the tracker,
|
||||||
// we keyed on `state` directly, every per-section
|
// so the veil can wait for artwork instead of guessing.
|
||||||
// hydration tick would re-run the 300ms crossfade, and
|
CompositionLocalProvider(
|
||||||
// first-sign-in (six sections cascading in) reads as
|
LocalArtSettleTracker provides viewModel.artTracker,
|
||||||
// continuous flicker. Keying on the class restricts the
|
) {
|
||||||
// animation to Loading↔Success↔Empty↔Error transitions and
|
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||||
// lets normal Success→Success recompositions update the
|
|
||||||
// LazyColumn without a fade.
|
|
||||||
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
|
||||||
when (val s = state) {
|
|
||||||
UiState.Loading -> HomeSkeletonContent()
|
|
||||||
UiState.Empty -> EmptyState(
|
|
||||||
title = "Welcome to Minstrel",
|
|
||||||
body = "Nothing to show yet — scan a folder in your server " +
|
|
||||||
"settings, then come back here for system playlists " +
|
|
||||||
"and recommendations.",
|
|
||||||
)
|
|
||||||
is UiState.Error -> ErrorRetry(
|
|
||||||
title = "Couldn't load home",
|
|
||||||
message = s.message,
|
|
||||||
onRetry = { viewModel.refresh() },
|
|
||||||
)
|
|
||||||
is UiState.Success -> HomeSuccessContent(
|
|
||||||
sections = s.data,
|
|
||||||
systemStatus = systemStatus,
|
|
||||||
offline = offline,
|
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
|
||||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
|
||||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
|
||||||
onMostPlayedTap = viewModel::playMostPlayed,
|
|
||||||
onPlayPool = viewModel::playPool,
|
|
||||||
onPlayAlbum = viewModel::playAlbum,
|
|
||||||
onPlayArtist = viewModel::playArtistShuffled,
|
|
||||||
onPlayPlaylist = viewModel::playPlaylist,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
// Refresh veil: rebuild / reconnect / pull / event churn all
|
||||||
|
// hide behind an "Updating your mixes…" wipe that stays up
|
||||||
|
// until the screen has actually stopped moving.
|
||||||
|
UpdatingVeil(visible = updating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
|
||||||
|
*
|
||||||
|
* Key the Crossfade on the state CLASS, not the instance. Each section
|
||||||
|
* emission produces a new UiState.Success(data); if we keyed on `state`
|
||||||
|
* directly, every per-section hydration tick would re-run the 300ms
|
||||||
|
* crossfade, and first-sign-in (six sections cascading in) reads as
|
||||||
|
* continuous flicker. Keying on the class restricts the animation to
|
||||||
|
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
|
||||||
|
* recompositions update the LazyColumn without a fade.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun HomeStateCrossfade(
|
||||||
|
state: UiState<HomeSections>,
|
||||||
|
systemStatus: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: HomeViewModel,
|
||||||
|
) {
|
||||||
|
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
||||||
|
when (val s = state) {
|
||||||
|
UiState.Loading -> HomeSkeletonContent()
|
||||||
|
UiState.Empty -> EmptyState(
|
||||||
|
title = "Welcome to Minstrel",
|
||||||
|
body = "Nothing to show yet — scan a folder in your server " +
|
||||||
|
"settings, then come back here for system playlists " +
|
||||||
|
"and recommendations.",
|
||||||
|
)
|
||||||
|
is UiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load home",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.retry() },
|
||||||
|
)
|
||||||
|
is UiState.Success -> HomeSuccessContent(
|
||||||
|
sections = s.data,
|
||||||
|
systemStatus = systemStatus,
|
||||||
|
offline = offline,
|
||||||
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||||
|
onMostPlayedTap = viewModel::playMostPlayed,
|
||||||
|
onPlayPool = viewModel::playPool,
|
||||||
|
onPlayAlbum = viewModel::playAlbum,
|
||||||
|
onPlayArtist = viewModel::playArtistShuffled,
|
||||||
|
onPlayPlaylist = viewModel::playPlaylist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-bleed "Updating your mixes…" veil that wipes in from the left,
|
||||||
|
* holds while an automatic refresh repopulates Home, then wipes off.
|
||||||
|
* Near-opaque so the section churn underneath never shows; swallows taps
|
||||||
|
* while raised so a mid-hydration tile can't be hit.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.UpdatingVeil(visible: Boolean) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
|
||||||
|
exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) {},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(VEIL_SPINNER_DP.dp),
|
||||||
|
strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
|
||||||
|
Text(
|
||||||
|
text = "Updating your mixes…",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -508,10 +754,12 @@ private fun HomeSuccessContent(
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
// Always rendered: real system/user playlists, with
|
// Always rendered: real system/user playlists, with
|
||||||
// placeholder cards filling the For You / Discover /
|
// placeholder cards filling the For You / Discover slots
|
||||||
// 3× Songs-like slots that haven't generated yet. When
|
// that haven't generated yet. When offline, the cache-backed
|
||||||
// offline, the cache-backed pool cards lead the row.
|
// pool cards lead the row. Songs-like now lives in its own
|
||||||
|
// dedicated row below (#1491), no longer inside this carousel.
|
||||||
PlaylistsRow(
|
PlaylistsRow(
|
||||||
|
title = "Playlists",
|
||||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||||
offline = offline,
|
offline = offline,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
@@ -519,6 +767,18 @@ private fun HomeSuccessContent(
|
|||||||
onPlayPlaylist = onPlayPlaylist,
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Songs-like is the best-performing surface (#1491) — promoted out
|
||||||
|
// of the Playlists carousel into its own row so it shows a wider
|
||||||
|
// spread of "Songs like {artist}" mixes. Hidden when there's
|
||||||
|
// nothing to show (offline with none cached).
|
||||||
|
songsLikeSection(
|
||||||
|
playlists = sections.playlists,
|
||||||
|
status = systemStatus,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
youMightLikeSection(
|
youMightLikeSection(
|
||||||
albums = sections.youMightLikeAlbums,
|
albums = sections.youMightLikeAlbums,
|
||||||
artists = sections.youMightLikeArtists,
|
artists = sections.youMightLikeArtists,
|
||||||
@@ -542,6 +802,35 @@ private fun HomeSuccessContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
|
||||||
|
* rendering with a distinct title; hidden entirely when there's nothing
|
||||||
|
* to show (offline with no cached mixes). Online with none generated yet
|
||||||
|
* still shows a few placeholders so the building / seed-needed state is
|
||||||
|
* visible, matching the pre-promotion carousel behavior.
|
||||||
|
*/
|
||||||
|
private fun LazyListScope.songsLikeSection(
|
||||||
|
playlists: List<PlaylistRef>,
|
||||||
|
status: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
onPlaylistClick: (String) -> Unit,
|
||||||
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
|
) {
|
||||||
|
val rowItems = buildSongsLikeRow(playlists, status, offline)
|
||||||
|
if (rowItems.isEmpty()) return
|
||||||
|
item {
|
||||||
|
PlaylistsRow(
|
||||||
|
title = "Songs like…",
|
||||||
|
rowItems = rowItems,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun LazyListScope.recentlyAddedSection(
|
private fun LazyListScope.recentlyAddedSection(
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
albums: List<HomeTile<AlbumRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
@@ -811,13 +1100,14 @@ private fun AlbumsRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaylistsRow(
|
private fun PlaylistsRow(
|
||||||
|
title: String,
|
||||||
rowItems: List<PlaylistRowItem>,
|
rowItems: List<PlaylistRowItem>,
|
||||||
offline: Boolean,
|
offline: Boolean,
|
||||||
onPlaylistClick: (String) -> Unit,
|
onPlaylistClick: (String) -> Unit,
|
||||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
HorizontalScrollRow(title = "Playlists") {
|
HorizontalScrollRow(title = title) {
|
||||||
itemsIndexed(items = rowItems) { _, item ->
|
itemsIndexed(items = rowItems) { _, item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
||||||
@@ -868,11 +1158,12 @@ enum class OfflinePoolKind(val label: String) {
|
|||||||
/**
|
/**
|
||||||
* Builds the Home Playlists row.
|
* Builds the Home Playlists row.
|
||||||
*
|
*
|
||||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
* Online: For You + Discover fixed slots (real card when generated,
|
||||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
* rediscover / new for you / on this day / first listens) when they exist —
|
||||||
* no placeholders for these since they're conditional on library shape — then
|
* no placeholders for these since they're conditional on library shape — then
|
||||||
* user-owned playlists.
|
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
|
||||||
|
* [buildSongsLikeRow] and no longer appears in this carousel.
|
||||||
*
|
*
|
||||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||||
* same real playlists in curated order but stably partitioned fully-cached
|
* same real playlists in curated order but stably partitioned fully-cached
|
||||||
@@ -899,7 +1190,7 @@ internal fun buildPlaylistsRow(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
||||||
private fun buildOnlineRow(
|
private fun buildOnlineRow(
|
||||||
owned: List<PlaylistRef>,
|
owned: List<PlaylistRef>,
|
||||||
status: SystemPlaylistsStatus,
|
status: SystemPlaylistsStatus,
|
||||||
@@ -911,12 +1202,7 @@ private fun buildOnlineRow(
|
|||||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
||||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
|
||||||
out += songsLike.getOrNull(i)
|
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
|
||||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
|
||||||
}
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||||
}
|
}
|
||||||
@@ -924,16 +1210,44 @@ private fun buildOnlineRow(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
||||||
|
* mixes the server generated, no longer capped to the 3 carousel slots.
|
||||||
|
*
|
||||||
|
* Online: every generated mix as a real card; when none exist yet, a few
|
||||||
|
* placeholders so the building / seed-needed state stays visible.
|
||||||
|
* Offline: the cached mixes only (fully-cached first, greyed after), and
|
||||||
|
* an empty list — hiding the whole section — when nothing is cached.
|
||||||
|
*/
|
||||||
|
internal fun buildSongsLikeRow(
|
||||||
|
owned: List<PlaylistRef>,
|
||||||
|
status: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
): List<PlaylistRowItem> {
|
||||||
|
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
|
||||||
|
return when {
|
||||||
|
offline -> {
|
||||||
|
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||||
|
(available + greyed).map { PlaylistRowItem.Real(it) }
|
||||||
|
}
|
||||||
|
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
|
||||||
|
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
|
||||||
|
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||||
* only differs by dropping placeholders + partitioning available-first.
|
* only differs by dropping placeholders + partitioning available-first.
|
||||||
|
* Songs-like is excluded here too — it renders in its own row via
|
||||||
|
* [buildSongsLikeRow] in both online and offline modes (#1491).
|
||||||
*/
|
*/
|
||||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||||
val out = mutableListOf<PlaylistRef>()
|
val out = mutableListOf<PlaylistRef>()
|
||||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||||
}
|
}
|
||||||
@@ -948,7 +1262,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
|||||||
else -> "pending"
|
else -> "pending"
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SONGS_LIKE_SLOTS = 3
|
// How many "Songs like…" placeholder cards the dedicated row shows while
|
||||||
|
// the mixes haven't generated yet (building / seed-needed). Real mixes,
|
||||||
|
// once generated, are shown in full and no longer capped by this (#1491).
|
||||||
|
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 5 system playlist kinds the server generates that aren't pinned
|
* The 5 system playlist kinds the server generates that aren't pinned
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
|
||||||
|
data class AdminTagSourceRef(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val requiresApiKey: Boolean,
|
||||||
|
val supports: List<String>,
|
||||||
|
val enabled: Boolean,
|
||||||
|
val apiKeySet: Boolean,
|
||||||
|
val testable: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Result of a provider "test connection" call. */
|
||||||
|
data class TagSourceTestResult(
|
||||||
|
val ok: Boolean,
|
||||||
|
val durationMs: Long = 0,
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.fabledsword.minstrel.models
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kind of Lidarr request being created. Wire form is the lowercase
|
* Kind of Lidarr request being created. Wire form is the lowercase
|
||||||
* enum name; the helper [wire] keeps that mapping in one place.
|
* enum name; the helper [wire] keeps that mapping in one place.
|
||||||
@@ -49,6 +52,8 @@ data class ArtistSuggestionRef(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val imageUrl: String = "",
|
val imageUrl: String = "",
|
||||||
val attribution: List<SeedContributionRef> = emptyList(),
|
val attribution: List<SeedContributionRef> = emptyList(),
|
||||||
|
/** Taste-profile tags this candidate matches, strongest first (#2377). */
|
||||||
|
val matchedTags: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val attributionText: String
|
val attributionText: String
|
||||||
get() {
|
get() {
|
||||||
@@ -63,7 +68,92 @@ data class ArtistSuggestionRef(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subtitle line for the card.
|
||||||
|
*
|
||||||
|
* Prefers the taste-tag reason over seed attribution when we have one,
|
||||||
|
* because it describes the MUSIC ("sounds like what you like") rather than
|
||||||
|
* the graph ("adjacent to something you played") — the whole point of
|
||||||
|
* milestone #268 slice 6. Falls back to attribution, which is the common
|
||||||
|
* case: tag coverage for out-of-library artists is partial by nature
|
||||||
|
* (#2376), so most candidates have no matched tags.
|
||||||
|
*
|
||||||
|
* Kept in lockstep with the web client's reasonText() in
|
||||||
|
* SuggestionFeed.svelte — same wording, same Oxford comma.
|
||||||
|
*/
|
||||||
|
val reasonText: String
|
||||||
|
get() {
|
||||||
|
val tags = matchedTags.take(MAX_ATTRIBUTION_PHRASES)
|
||||||
|
return when (tags.size) {
|
||||||
|
0 -> attributionText
|
||||||
|
1 -> "Matches your taste in ${tags[0]}."
|
||||||
|
2 -> "Matches your taste in ${tags[0]} and ${tags[1]}."
|
||||||
|
else -> "Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_ATTRIBUTION_PHRASES = 3
|
private const val MAX_ATTRIBUTION_PHRASES = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A suggestion the user parked with "not right now" (#2374).
|
||||||
|
*
|
||||||
|
* Deliberately NOT a dislike: it carries no verdict on the artist, expires on
|
||||||
|
* its own, and never reaches the taste profile. Anything that treats this as
|
||||||
|
* negative preference signal is a bug.
|
||||||
|
*
|
||||||
|
* [snoozedUntil] is the raw RFC3339 string from the wire. Only the server
|
||||||
|
* decides whether a snooze is still in effect — every row the client receives
|
||||||
|
* already is — so this is read purely to phrase "back in about 3 months".
|
||||||
|
*/
|
||||||
|
data class SuggestionSnoozeRef(
|
||||||
|
val mbid: String,
|
||||||
|
val name: String,
|
||||||
|
val snoozedUntil: String,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Relative return phrase for the manage list. Relative rather than a
|
||||||
|
* calendar date because the exact day a 90-day snooze lapses is noise the
|
||||||
|
* user never asked for.
|
||||||
|
*
|
||||||
|
* [nowMs] is injectable so this is testable without freezing the clock.
|
||||||
|
* Returns "shortly" for an unparseable or already-past timestamp: the row
|
||||||
|
* is on screen, so the server still considers it snoozed, and guessing is
|
||||||
|
* better than rendering an empty line.
|
||||||
|
*/
|
||||||
|
fun returnsIn(nowMs: Long = System.currentTimeMillis()): String {
|
||||||
|
val remainingMs = runCatching { Instant.parse(snoozedUntil).toEpochMilliseconds() }
|
||||||
|
.getOrNull()?.minus(nowMs)
|
||||||
|
// Two ways to have nothing to state: an unparseable timestamp, or one
|
||||||
|
// already lapsed by our clock though the server still returned the row
|
||||||
|
// (the two disagree). Neither is "today", which would read as a real
|
||||||
|
// prediction.
|
||||||
|
if (remainingMs == null || remainingMs <= 0) return "shortly"
|
||||||
|
val days = (remainingMs.toDouble() / MILLIS_PER_DAY).roundToInt()
|
||||||
|
return when {
|
||||||
|
days < 1 -> "today"
|
||||||
|
days == 1 -> "tomorrow"
|
||||||
|
days < DAYS_BEFORE_MONTHS -> "in $days days"
|
||||||
|
else -> {
|
||||||
|
val months = (days.toDouble() / DAYS_PER_MONTH).roundToInt()
|
||||||
|
if (months == 1) "in about a month" else "in about $months months"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MILLIS_PER_DAY = 86_400_000.0
|
||||||
|
|
||||||
|
// Below this, days read more naturally than a rounded month count.
|
||||||
|
//
|
||||||
|
// Must be <= DAYS_PER_MONTH, or the singular "in about a month" is
|
||||||
|
// unreachable: a rounded month count of 1 needs 15..44 days, and any
|
||||||
|
// threshold above 30 sends all of those down the days branch instead.
|
||||||
|
// This was 45 and the singular branch was dead code — the unit test
|
||||||
|
// for it is what surfaced that.
|
||||||
|
const val DAYS_BEFORE_MONTHS = 30
|
||||||
|
const val DAYS_PER_MONTH = 30.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
|
||||||
|
* Every field defaults defensively so a missing JSON key never crashes
|
||||||
|
* deserialization. `versionBumped` is present only on the PATCH response.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AdminTagSourceWire(
|
||||||
|
val id: String = "",
|
||||||
|
@SerialName("display_name") val displayName: String = "",
|
||||||
|
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
|
||||||
|
val supports: List<String> = emptyList(),
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
@SerialName("api_key_set") val apiKeySet: Boolean = false,
|
||||||
|
@SerialName("display_order") val displayOrder: Int = 0,
|
||||||
|
val testable: Boolean = false,
|
||||||
|
@SerialName("version_bumped") val versionBumped: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminTagSourcesListWire(
|
||||||
|
val providers: List<AdminTagSourceWire> = emptyList(),
|
||||||
|
@SerialName("sources_version") val sourcesVersion: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TestTagSourceWire(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
@SerialName("duration_ms") val durationMs: Long = 0,
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
@@ -35,6 +35,13 @@ data class ArtistSuggestionWire(
|
|||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("image_url") val imageUrl: String = "",
|
@SerialName("image_url") val imageUrl: String = "",
|
||||||
val attribution: List<SeedContributionWire> = emptyList(),
|
val attribution: List<SeedContributionWire> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Tags this candidate shares with the user's taste profile, strongest
|
||||||
|
* first (max 3, #2377). Absent for most candidates — tag coverage for
|
||||||
|
* out-of-library artists is partial by nature (#2376) — so the default
|
||||||
|
* empty list is the common case, not an error.
|
||||||
|
*/
|
||||||
|
@SerialName("matched_tags") val matchedTags: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +55,36 @@ data class SeedContributionWire(
|
|||||||
@SerialName("is_liked") val isLiked: Boolean = false,
|
@SerialName("is_liked") val isLiked: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /api/discover/snoozes` — a suggestion the user parked
|
||||||
|
* with "not right now". The server only returns rows that are still in
|
||||||
|
* effect, so the client never compares [snoozedUntil] against the clock to
|
||||||
|
* decide whether to show it; it reads it only to say when the artist comes
|
||||||
|
* back.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SuggestionSnoozeWire(
|
||||||
|
val mbid: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
@SerialName("snoozed_until") val snoozedUntil: String = "",
|
||||||
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `POST /api/discover/suggestions/{mbid}/snooze`.
|
||||||
|
*
|
||||||
|
* [name] is required by the server, not decorative: suggestions are
|
||||||
|
* out-of-library, so there is no artists row to resolve a display name from
|
||||||
|
* and the snooze list would have nothing to render. Omitting it is a 400.
|
||||||
|
*
|
||||||
|
* No `days` field. The duration is the server's to own (90 days); pinning it
|
||||||
|
* client-side would freeze the default at whatever this build shipped.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SnoozeSuggestionBody(
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
||||||
* payload shape. Optional fields are emitted only when non-null
|
* payload shape. Optional fields are emitted only when non-null
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
|
|||||||
@SerialName("track_id") val trackId: String,
|
@SerialName("track_id") val trackId: String,
|
||||||
@SerialName("client_id") val clientId: String,
|
@SerialName("client_id") val clientId: String,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning (server normalizes).
|
||||||
|
@SerialName("device_class") val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
|
|||||||
val at: String,
|
val at: String,
|
||||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning (server normalizes).
|
||||||
|
@SerialName("device_class") val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
|
|||||||
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
||||||
|
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
||||||
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
composable<AdminTagSources> {
|
||||||
|
WithAnimatedScope {
|
||||||
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
|
AdminTagSourcesScreen(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable data object AdminRequests
|
@Serializable data object AdminRequests
|
||||||
@Serializable data object AdminQuarantine
|
@Serializable data object AdminQuarantine
|
||||||
@Serializable data object AdminUsers
|
@Serializable data object AdminUsers
|
||||||
|
@Serializable data object AdminTagSources
|
||||||
|
|
||||||
// ── Outside-shell full-screen routes ──────────────────────────────────
|
// ── Outside-shell full-screen routes ──────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -58,8 +59,10 @@ import timber.log.Timber
|
|||||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||||
* advance natively. PollLoop syncs the local cursor by comparing the
|
* advance natively. PollLoop keeps the local cursor aligned to the
|
||||||
* 1-based Track index from GetPositionInfo.
|
* track the renderer is actually playing, matched by track identity
|
||||||
|
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
|
||||||
|
* index drift -- see [syncLocalCursorToRemote].
|
||||||
*/
|
*/
|
||||||
class MinstrelForwardingPlayer(
|
class MinstrelForwardingPlayer(
|
||||||
private val delegate: Player,
|
private val delegate: Player,
|
||||||
@@ -339,6 +342,23 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The system media notification / lock-screen / Android Auto / Wear 'next'
|
||||||
|
// and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
|
||||||
|
// -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
|
||||||
|
// *MediaItem variants the in-app transport buttons call. Un-overridden, the
|
||||||
|
// base ForwardingPlayer forwards these to the paused local delegate -- so
|
||||||
|
// the notification next/prev only nudged the local cursor (which the poll
|
||||||
|
// then re-synced back to Sonos), reading as dead buttons while casting.
|
||||||
|
// Route them through the same Sonos path as the media-item variants when a
|
||||||
|
// UPnP route is engaged; plain local playback keeps the default behaviour.
|
||||||
|
override fun seekToNext() {
|
||||||
|
if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekToPrevious() {
|
||||||
|
if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
override fun getCurrentPosition(): Long =
|
override fun getCurrentPosition(): Long =
|
||||||
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
||||||
|
|
||||||
@@ -526,18 +546,8 @@ class MinstrelForwardingPlayer(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One poll tick: read position + transport state from Sonos, apply to
|
* One poll tick: read position + transport state from Sonos, apply to
|
||||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
* [remoteState], and sync the local cursor to the track the renderer is
|
||||||
* index when not in queue load.
|
* actually playing (see [syncLocalCursorToRemote]).
|
||||||
*
|
|
||||||
* Cursor sync is gated on `holder.target == null` (= not loading)
|
|
||||||
* because during load Sonos reports Track=1 while we're still
|
|
||||||
* appending, and syncing would race the SetAV+Seek that lands
|
|
||||||
* after. Outside load, forward sync catches Sonos auto-advances
|
|
||||||
* (queue end-of-track), Sonos-app driven Next presses, and any
|
|
||||||
* drift after a brief poll-failure burst that didn't trip the
|
|
||||||
* drop threshold. Forward-only because a Next override we just
|
|
||||||
* issued can race with a poll still reporting the prior Track --
|
|
||||||
* the next poll catches up safely.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||||
val info = active.avTransport.getPositionInfo()
|
val info = active.avTransport.getPositionInfo()
|
||||||
@@ -553,7 +563,7 @@ class MinstrelForwardingPlayer(
|
|||||||
trackUri = info.trackUri,
|
trackUri = info.trackUri,
|
||||||
trackNumber = info.track,
|
trackNumber = info.track,
|
||||||
)
|
)
|
||||||
maybeSyncLocalCursor(info.track)
|
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||||
val transport = active.avTransport.getTransportInfo()
|
val transport = active.avTransport.getTransportInfo()
|
||||||
when (transport.state) {
|
when (transport.state) {
|
||||||
TransportState.PLAYING -> {
|
TransportState.PLAYING -> {
|
||||||
@@ -577,23 +587,95 @@ class MinstrelForwardingPlayer(
|
|||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
/**
|
||||||
if (holder.target.value != null) return
|
* Align the paused local delegate cursor to the track the renderer is
|
||||||
if (sonosTrack <= 0) return
|
* actually playing, so the un-overridden current-item getters
|
||||||
val sonosIdx = sonosTrack - 1
|
* (getCurrentMediaItem/Index -- what both the in-app player UI via
|
||||||
|
* PlayerController.onEvents AND the MediaSession notification read) always
|
||||||
|
* resolve to that track. This is THE single source of truth for "what's
|
||||||
|
* playing" during a cast.
|
||||||
|
*
|
||||||
|
* Identity-first: match the track-id embedded in the renderer's current URI
|
||||||
|
* against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
|
||||||
|
* TrackRef.id), so the index-offset wobble across queue reloads / re-casts
|
||||||
|
* can never park the cursor on a stale (pre-cast) track. Falls back to the
|
||||||
|
* 1-based Sonos Track index only when the URI can't be resolved to a queue
|
||||||
|
* item. Unlike the old forward-only sync this moves the cursor in BOTH
|
||||||
|
* directions -- a renderer Previous / re-cast to a lower track pulls it back
|
||||||
|
* too. Runs on the application looper (delegate access contract).
|
||||||
|
*
|
||||||
|
* Suppressed while:
|
||||||
|
* - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
|
||||||
|
* (re)uploaded, so its Track index is meaningless; and
|
||||||
|
* - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
|
||||||
|
* the override already advanced the delegate optimistically, and Sonos
|
||||||
|
* still reports the OLD track for a beat, so syncing now would undo the
|
||||||
|
* user's press. The pending deadline is honoured directly so a missed
|
||||||
|
* clear can't wedge the sync.
|
||||||
|
*/
|
||||||
|
private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
|
||||||
|
if (isLoadingUpnp()) return
|
||||||
|
val pendingDeadline = remoteState.pendingTransportDeadlineMs
|
||||||
|
if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
|
||||||
handler.post {
|
handler.post {
|
||||||
val localIdx = delegate.currentMediaItemIndex
|
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
|
||||||
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
if (target != delegate.currentMediaItemIndex) {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
"UPnP cursor sync: local=%d -> %d",
|
||||||
localIdx, sonosIdx,
|
delegate.currentMediaItemIndex, target,
|
||||||
)
|
)
|
||||||
delegate.seekTo(sonosIdx, 0L)
|
delegate.seekTo(target, 0L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most-authoritative -> most-degraded (project rule #48): the track-id
|
||||||
|
* parsed from the renderer's current URI wins; else the 1-based Sonos Track
|
||||||
|
* index; else null (leave the cursor put rather than surface a wrong track).
|
||||||
|
* Must run on the application looper -- reads the delegate timeline.
|
||||||
|
*/
|
||||||
|
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
|
||||||
|
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
|
||||||
|
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
|
||||||
|
}
|
||||||
|
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
|
||||||
|
* occurrence nearest [preferNear] so a track that appears twice in the queue
|
||||||
|
* doesn't snap the cursor across the queue. null if the id isn't present.
|
||||||
|
*/
|
||||||
|
private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
|
||||||
|
var best: Int? = null
|
||||||
|
var bestDist = Int.MAX_VALUE
|
||||||
|
for (i in 0 until delegate.mediaItemCount) {
|
||||||
|
if (delegate.getMediaItemAt(i).mediaId == mediaId) {
|
||||||
|
val dist = abs(i - preferNear)
|
||||||
|
if (dist < bestDist) {
|
||||||
|
best = i
|
||||||
|
bestDist = dist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
|
||||||
|
private fun trackIdFromStreamUri(uri: String): String? {
|
||||||
|
val start = uri.indexOf(TRACKS_PATH_MARKER)
|
||||||
|
if (start < 0) return null
|
||||||
|
val idStart = start + TRACKS_PATH_MARKER.length
|
||||||
|
val idEnd = uri.indexOf('/', idStart)
|
||||||
|
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
// Path segment that precedes the track-id in a stream URI
|
||||||
|
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
|
||||||
|
// back to a delegate MediaItem by identity.
|
||||||
|
const val TRACKS_PATH_MARKER = "/api/tracks/"
|
||||||
const val POLL_INTERVAL_MS = 1_000L
|
const val POLL_INTERVAL_MS = 1_000L
|
||||||
const val NON_PLAYING_CONFIRM = 2
|
const val NON_PLAYING_CONFIRM = 2
|
||||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
clientId = cid,
|
clientId = cid,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
|
deviceClass = DEVICE_CLASS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (curTrackId == trackId) {
|
if (curTrackId == trackId) {
|
||||||
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
atIso = startedAt.toString(),
|
atIso = startedAt.toString(),
|
||||||
durationPlayedMs = durationPlayedMs,
|
durationPlayedMs = durationPlayedMs,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
|
deviceClass = DEVICE_CLASS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
|
|
||||||
// ── Lifecycle: durable-close on app background / detach ────────
|
// ── Lifecycle: durable-close on app background / detach ────────
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// #1551: Android is a mobile client. Refine to tablet/tv via device
|
||||||
|
// configuration later if the metrics trend view shows it matters.
|
||||||
|
private const val DEVICE_CLASS = "mobile"
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStop(owner: LifecycleOwner) {
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||||
// alone: the foreground media service keeps the process — and this
|
// alone: the foreground media service keeps the process — and this
|
||||||
|
|||||||
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
|
|||||||
controller.addMediaItem(track.toMediaItem(source = null))
|
controller.addMediaItem(track.toMediaItem(source = null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder the queue: move the item at [from] to [to], keeping the domain
|
||||||
|
* snapshot in lock-step with the player's MediaItem timeline. Media3 emits
|
||||||
|
* onEvents → uiState reflects the new order (and the still-playing item's
|
||||||
|
* index). No-op on bad indices or a no-move.
|
||||||
|
*/
|
||||||
|
fun moveInQueue(from: Int, to: Int) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
|
||||||
|
queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
|
||||||
|
controller.moveMediaItem(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the queue item at [index]. When it's the currently-playing item
|
||||||
|
* Media3 advances to the next automatically. No-op on a bad index.
|
||||||
|
*/
|
||||||
|
fun removeFromQueue(index: Int) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (index !in queueRefs.indices) return
|
||||||
|
queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
|
||||||
|
controller.removeMediaItem(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty the queue and stop playback. */
|
||||||
|
fun clearQueue() {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
queueRefs = emptyList()
|
||||||
|
controller.clearMediaItems()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
||||||
* "radio:<id>" so the server-side rotation reporter can
|
* "radio:<id>" so the server-side rotation reporter can
|
||||||
@@ -598,18 +629,19 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One position-polling tick. Owns track-change detection too: when UPnP
|
* One position-polling tick. Patches ONLY the smoothly-changing transport
|
||||||
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
* fields (position / duration / play-pause / buffer) via `.copy()`. The
|
||||||
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
* queue index + current track are owned solely by [onEvents], which reads
|
||||||
* uiState.queueIndex stuck on the old track even after Sonos has
|
* the authoritative delegate cursor: during a cast,
|
||||||
* advanced. So the tick reads Sonos's reported Track as the source of
|
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
|
||||||
* truth, rebuilds the index/title fields itself, and force-syncs the
|
* the track the renderer is actually playing (matched by identity), so there
|
||||||
* wrapped player as defense in depth.
|
* is exactly one writer of "what's playing" and a stale tick can never
|
||||||
|
* revert it to the old track. (Previously the tick was a second index writer
|
||||||
|
* that fought onEvents — the flicker-to-stale-track bug.)
|
||||||
*/
|
*/
|
||||||
private fun tickPositionPoll(controller: MediaController) {
|
private fun tickPositionPoll(controller: MediaController) {
|
||||||
val upnpActive = activeUpnpHolder.active.value != null
|
val upnpActive = activeUpnpHolder.active.value != null
|
||||||
resolvePendingTransport(controller, upnpActive)
|
resolvePendingTransport(controller, upnpActive)
|
||||||
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
|
|
||||||
val effectiveIsPlaying =
|
val effectiveIsPlaying =
|
||||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||||
val effectivePosition = if (upnpActive) {
|
val effectivePosition = if (upnpActive) {
|
||||||
@@ -617,27 +649,29 @@ class PlayerController @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
controller.currentPosition
|
controller.currentPosition
|
||||||
}
|
}
|
||||||
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
val idx = controller.currentMediaItemIndex
|
||||||
val current = uiStateInternal.value
|
val current = uiStateInternal.value
|
||||||
val newPos = effectivePosition.coerceAtLeast(0)
|
val newPos = effectivePosition.coerceAtLeast(0)
|
||||||
val newDur = effectiveDuration(
|
val newDur = effectiveDuration(
|
||||||
upnpActive,
|
upnpActive,
|
||||||
remoteState.durationMs,
|
remoteState.durationMs,
|
||||||
controller.duration,
|
controller.duration,
|
||||||
desiredIdx = desiredIdx,
|
desiredIdx = idx,
|
||||||
controllerIdx = controller.currentMediaItemIndex,
|
controllerIdx = idx,
|
||||||
)
|
)
|
||||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||||
// Track adjustments are forward-only AND suppressed while a user
|
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
|
||||||
// transport press is pending Sonos confirmation. Together those keep
|
current.positionMs != newPos ||
|
||||||
// either direction of user input from being undone by a stale poll.
|
current.durationMs != newDur ||
|
||||||
val trackChanged = !pendingTransport &&
|
current.bufferedPositionMs != newBuf
|
||||||
desiredIdx > current.queueIndex &&
|
if (somethingChanged) {
|
||||||
desiredIdx in queueRefs.indices
|
uiStateInternal.value = current.copy(
|
||||||
publishTickIfChanged(
|
isPlaying = effectiveIsPlaying,
|
||||||
current, trackChanged, desiredIdx,
|
positionMs = newPos,
|
||||||
effectiveIsPlaying, newPos, newDur, newBuf,
|
durationMs = newDur,
|
||||||
)
|
bufferedPositionMs = newBuf,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -654,48 +688,6 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
|
|
||||||
private fun publishTickIfChanged(
|
|
||||||
current: PlayerUiState,
|
|
||||||
trackChanged: Boolean,
|
|
||||||
desiredIdx: Int,
|
|
||||||
effectiveIsPlaying: Boolean,
|
|
||||||
newPos: Long,
|
|
||||||
newDur: Long,
|
|
||||||
newBuf: Long,
|
|
||||||
) {
|
|
||||||
val somethingChanged = trackChanged ||
|
|
||||||
current.isPlaying != effectiveIsPlaying ||
|
|
||||||
current.positionMs != newPos ||
|
|
||||||
current.durationMs != newDur
|
|
||||||
if (!somethingChanged) return
|
|
||||||
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
|
|
||||||
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
|
|
||||||
uiStateInternal.value = current.copy(
|
|
||||||
currentTrack = newTrack,
|
|
||||||
queueIndex = newIdx,
|
|
||||||
isPlaying = effectiveIsPlaying,
|
|
||||||
positionMs = newPos,
|
|
||||||
durationMs = newDur,
|
|
||||||
bufferedPositionMs = newBuf,
|
|
||||||
)
|
|
||||||
// Intentionally do NOT call controller.seekTo here. That would route
|
|
||||||
// through MinstrelForwardingPlayer's seekTo override and re-issue
|
|
||||||
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
|
|
||||||
// start of the same track it's already playing, restarting the song.
|
|
||||||
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
|
|
||||||
// delegate.seekTo (which bypasses the override). If it lags briefly,
|
|
||||||
// the next pollOnce catches up; the uiState above already reflects
|
|
||||||
// Sonos's truth for the user.
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
|
|
||||||
if (upnpActive) {
|
|
||||||
(remoteState.trackNumber - 1).coerceAtLeast(0)
|
|
||||||
} else {
|
|
||||||
controller.currentMediaItemIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Remote position interpolation state ──────────────────────────────
|
// ── Remote position interpolation state ──────────────────────────────
|
||||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package com.fabledsword.minstrel.player.ui
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
import com.fabledsword.minstrel.player.PlayerUiState
|
import com.fabledsword.minstrel.player.PlayerUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,13 +25,29 @@ import javax.inject.Inject
|
|||||||
* stub-test for ViewModel-level logic when it grows).
|
* stub-test for ViewModel-level logic when it grows).
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
|
||||||
class PlayerViewModel @Inject constructor(
|
class PlayerViewModel @Inject constructor(
|
||||||
private val controller: PlayerController,
|
private val controller: PlayerController,
|
||||||
|
private val likes: LikesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||||
val dropEvents: SharedFlow<String> = controller.dropEvents
|
val dropEvents: SharedFlow<String> = controller.dropEvents
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive set of liked track ids — the set-based pattern the queue
|
||||||
|
* (and playlist/album/artist detail) uses to color each row's
|
||||||
|
* [com.fabledsword.minstrel.shared.widgets.LikeButton] without a
|
||||||
|
* per-row Flow subscription.
|
||||||
|
*/
|
||||||
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
|
likes.observeLikedTrackIds()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
|
initialValue = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
fun play() = controller.play()
|
fun play() = controller.play()
|
||||||
fun pause() = controller.pause()
|
fun pause() = controller.pause()
|
||||||
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
||||||
@@ -35,4 +56,16 @@ class PlayerViewModel @Inject constructor(
|
|||||||
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
||||||
fun toggleShuffle() = controller.toggleShuffle()
|
fun toggleShuffle() = controller.toggleShuffle()
|
||||||
fun cycleRepeat() = controller.cycleRepeat()
|
fun cycleRepeat() = controller.cycleRepeat()
|
||||||
|
fun moveInQueue(from: Int, to: Int) = controller.moveInQueue(from, to)
|
||||||
|
fun removeFromQueue(index: Int) = controller.removeFromQueue(index)
|
||||||
|
fun clearQueue() = controller.clearQueue()
|
||||||
|
|
||||||
|
fun toggleLikeTrack(trackId: String) {
|
||||||
|
val desired = trackId !in likedTrackIds.value
|
||||||
|
viewModelScope.launch {
|
||||||
|
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SwipeToDismissBox
|
||||||
|
import androidx.compose.material3.SwipeToDismissBoxValue
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberSwipeToDismissBoxState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||||
|
import androidx.compose.ui.semantics.customActions
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Music
|
||||||
|
import com.composables.icons.lucide.Trash2
|
||||||
|
import com.composables.icons.lucide.Volume2
|
||||||
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
|
import com.fabledsword.minstrel.theme.LocalActionColors
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A single queue row, split out of QueueScreen.kt when swipe-to-remove (#2435)
|
||||||
|
* pushed that file past detekt's TooManyFunctions limit. The seam is real and
|
||||||
|
* not just a way to satisfy the analyzer: the row now carries two gestures, a
|
||||||
|
* swipe background, and its own accessibility surface, which is more behaviour
|
||||||
|
* than the screen that lists it. `internal` rather than `private` only because
|
||||||
|
* QueueList (still in QueueScreen.kt) is the caller.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||||
|
@Composable
|
||||||
|
internal fun QueueRow(
|
||||||
|
track: TrackRef,
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
|
isCurrent: Boolean,
|
||||||
|
liked: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onToggleLike: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||||
|
var rowHeightPx by remember { mutableIntStateOf(0) }
|
||||||
|
val highlight = if (isCurrent) {
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||||
|
} else {
|
||||||
|
Color.Transparent
|
||||||
|
}
|
||||||
|
// Swipe left to remove, replacing the X button (#2395 follow-up). Only
|
||||||
|
// end-to-start is enabled: a right-swipe has no meaning here, and leaving it
|
||||||
|
// live would delete tracks on a mis-aimed gesture in either direction.
|
||||||
|
val dismissState = rememberSwipeToDismissBoxState(
|
||||||
|
confirmValueChange = { value ->
|
||||||
|
if (value == SwipeToDismissBoxValue.EndToStart) {
|
||||||
|
onRemove()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SwipeToDismissBox(
|
||||||
|
state = dismissState,
|
||||||
|
enableDismissFromStartToEnd = false,
|
||||||
|
backgroundContent = { RemoveSwipeBackground() },
|
||||||
|
// The reorder lift lives out here so a row being dragged vertically
|
||||||
|
// carries its swipe container with it rather than sliding out of one.
|
||||||
|
modifier = Modifier
|
||||||
|
.onSizeChanged { rowHeightPx = it.height }
|
||||||
|
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||||
|
.graphicsLayer { translationY = dragOffsetY },
|
||||||
|
) {
|
||||||
|
QueueRowContent(
|
||||||
|
track = track,
|
||||||
|
index = index,
|
||||||
|
queueSize = queueSize,
|
||||||
|
isCurrent = isCurrent,
|
||||||
|
liked = liked,
|
||||||
|
highlight = highlight,
|
||||||
|
rowHeightPx = rowHeightPx,
|
||||||
|
onClick = onClick,
|
||||||
|
onToggleLike = onToggleLike,
|
||||||
|
onRemove = onRemove,
|
||||||
|
onMove = onMove,
|
||||||
|
onDragOffset = { dragOffsetY = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||||
|
@Composable
|
||||||
|
private fun QueueRowContent(
|
||||||
|
track: TrackRef,
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
|
isCurrent: Boolean,
|
||||||
|
liked: Boolean,
|
||||||
|
highlight: Color,
|
||||||
|
rowHeightPx: Int,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onToggleLike: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
onDragOffset: (Float) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// Opaque: this sits ON TOP of the red remove background, so a
|
||||||
|
// transparent row would show the fill through it at rest.
|
||||||
|
.background(MaterialTheme.colorScheme.surface)
|
||||||
|
.background(highlight)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.queueReorderActions(
|
||||||
|
index = index,
|
||||||
|
queueSize = queueSize,
|
||||||
|
onMove = onMove,
|
||||||
|
onRemove = onRemove,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
// The album art IS the grab surface (#2395). The grip icon it replaces
|
||||||
|
// cost ~36dp of every row's width — icon plus its 12dp gap — on the
|
||||||
|
// narrowest surface in the app, competing with the title for space.
|
||||||
|
QueueRowThumbnail(
|
||||||
|
track = track,
|
||||||
|
dragModifier = Modifier.queueReorderDrag(
|
||||||
|
index = index,
|
||||||
|
queueSize = queueSize,
|
||||||
|
rowHeightPx = rowHeightPx,
|
||||||
|
onOffsetChange = onDragOffset,
|
||||||
|
onMove = onMove,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (isCurrent) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Volume2,
|
||||||
|
contentDescription = "Now playing",
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
||||||
|
if (track.durationSec > 0) {
|
||||||
|
Text(
|
||||||
|
text = formatDuration(track.durationSec),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the row slides off to reveal: the destructive colour with a trash glyph,
|
||||||
|
* pinned to the trailing edge because that is the edge the swipe uncovers.
|
||||||
|
*
|
||||||
|
* Oxblood (LocalActionColors.destructive), NOT colorScheme.error. The design
|
||||||
|
* system keeps those apart deliberately — an error is a failure that already
|
||||||
|
* happened, a destructive action is one about to happen — and using the error
|
||||||
|
* colour here would dress an intentional gesture as a fault report.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun RemoveSwipeBackground() {
|
||||||
|
val actions = LocalActionColors.current
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(actions.destructive)
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
contentAlignment = Alignment.CenterEnd,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Lucide.Trash2, contentDescription = null, tint = actions.onAction)
|
||||||
|
Text(
|
||||||
|
text = "Remove",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = actions.onAction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Screen-reader reordering and removal for a queue row.
|
||||||
|
*
|
||||||
|
* Both gestures this row now relies on — long-press-drag to reorder, swipe to
|
||||||
|
* remove — are touch-only and unavailable under TalkBack, and each replaced a
|
||||||
|
* control that a screen reader COULD find (the grip's "Reorder track", the X's
|
||||||
|
* "Remove from queue"). Without these actions the row would have lost both
|
||||||
|
* capabilities for anyone not using touch. They're the Android counterpart to
|
||||||
|
* the web row's ArrowUp/ArrowDown keys and its still-present X button.
|
||||||
|
*/
|
||||||
|
private fun Modifier.queueReorderActions(
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
): Modifier = semantics {
|
||||||
|
customActions = listOf(
|
||||||
|
CustomAccessibilityAction("Move up") {
|
||||||
|
if (index > 0) { onMove(index, index - 1); true } else false
|
||||||
|
},
|
||||||
|
CustomAccessibilityAction("Move down") {
|
||||||
|
if (index < queueSize - 1) { onMove(index, index + 1); true } else false
|
||||||
|
},
|
||||||
|
CustomAccessibilityAction("Remove from queue") { onRemove(); true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder-drag behaviour for a queue row, applied to whatever element is the
|
||||||
|
* grab surface — the album art, since #2395 removed the grip icon.
|
||||||
|
*
|
||||||
|
* Uses **detectDragGesturesAfterLongPress**, not detectDragGestures, and that
|
||||||
|
* is the load-bearing detail. The grip was a small target, so a plain drag
|
||||||
|
* gesture on it never competed with anything. A 48dp thumbnail is a large
|
||||||
|
* chunk of every row, and with a plain drag detector any vertical pan starting
|
||||||
|
* on artwork would be swallowed as a row-reorder instead of scrolling the
|
||||||
|
* queue — the list would feel broken precisely where it's easiest to touch.
|
||||||
|
* Long-press-then-drag separates the two: pan scrolls, long-press reorders,
|
||||||
|
* tap still plays (the detector doesn't consume a plain tap, so it falls
|
||||||
|
* through to the row's clickable).
|
||||||
|
*/
|
||||||
|
private fun Modifier.queueReorderDrag(
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
|
rowHeightPx: Int,
|
||||||
|
onOffsetChange: (Float) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
): Modifier = composed {
|
||||||
|
// Mirrors the web queue: the row follows the finger during a drag, then on
|
||||||
|
// release we translate the accumulated offset into a row delta and reorder.
|
||||||
|
var offset by remember { mutableFloatStateOf(0f) }
|
||||||
|
pointerInput(index, queueSize, rowHeightPx) {
|
||||||
|
detectDragGesturesAfterLongPress(
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
offset += dragAmount.y
|
||||||
|
onOffsetChange(offset)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
|
||||||
|
val target = (index + delta).coerceIn(0, queueSize - 1)
|
||||||
|
if (target != index) onMove(index, target)
|
||||||
|
offset = 0f
|
||||||
|
onOffsetChange(0f)
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
offset = 0f
|
||||||
|
onOffsetChange(0f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRowThumbnail(track: TrackRef, dragModifier: Modifier = Modifier) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.then(dragModifier),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
ServerImage(
|
||||||
|
url = track.coverUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Music,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = track.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
val subtitle = queueSubtitle(track)
|
||||||
|
if (subtitle.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Artist · Album" — collapses gracefully when either is missing. */
|
||||||
|
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.joinToString(" · ")
|
||||||
|
|
||||||
|
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
package com.fabledsword.minstrel.player.ui
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -20,22 +21,24 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.ArrowDown
|
||||||
import com.composables.icons.lucide.ArrowLeft
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.Volume2
|
import com.composables.icons.lucide.Trash2
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -44,16 +47,35 @@ fun QueueScreen(
|
|||||||
viewModel: PlayerViewModel = hiltViewModel(),
|
viewModel: PlayerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Queue") },
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("Queue")
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = queueSummary(state.queue),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navController.popBackStack() }) {
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
IconButton(onClick = viewModel::clearQueue) {
|
||||||
|
Icon(Lucide.Trash2, contentDescription = "Clear queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
@@ -67,87 +89,106 @@ fun QueueScreen(
|
|||||||
QueueList(
|
QueueList(
|
||||||
tracks = state.queue,
|
tracks = state.queue,
|
||||||
currentIndex = state.queueIndex,
|
currentIndex = state.queueIndex,
|
||||||
|
likedTrackIds = likedTrackIds,
|
||||||
onJumpTo = viewModel::seekToIndex,
|
onJumpTo = viewModel::seekToIndex,
|
||||||
|
onToggleLike = viewModel::toggleLikeTrack,
|
||||||
|
onMove = viewModel::moveInQueue,
|
||||||
|
onRemove = viewModel::removeFromQueue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueList(
|
private fun QueueList(
|
||||||
tracks: List<TrackRef>,
|
tracks: List<TrackRef>,
|
||||||
currentIndex: Int,
|
currentIndex: Int,
|
||||||
|
likedTrackIds: Set<String>,
|
||||||
onJumpTo: (Int) -> Unit,
|
onJumpTo: (Int) -> Unit,
|
||||||
|
onToggleLike: (String) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
onRemove: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
val listState = rememberLazyListState(
|
||||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||||
QueueRow(
|
)
|
||||||
track = track,
|
val scope = rememberCoroutineScope()
|
||||||
isCurrent = index == currentIndex,
|
|
||||||
onClick = { onJumpTo(index) },
|
// Follow the now-playing row as the track auto-advances, but only while it's
|
||||||
)
|
// near the visible window — if the user has scrolled away to browse, leave
|
||||||
HorizontalDivider()
|
// them there (the pill offers the way back). Parity with the web queue.
|
||||||
|
LaunchedEffect(currentIndex) {
|
||||||
|
if (currentIndex < 0) return@LaunchedEffect
|
||||||
|
val visible = listState.layoutInfo.visibleItemsInfo
|
||||||
|
val first = visible.firstOrNull()?.index ?: 0
|
||||||
|
val last = visible.lastOrNull()?.index ?: 0
|
||||||
|
if (currentIndex in (first - 1)..(last + 1)) {
|
||||||
|
listState.animateScrollToItem(currentIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val currentVisible by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.layoutInfo.visibleItemsInfo.any { it.index == currentIndex }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||||
|
QueueRow(
|
||||||
|
track = track,
|
||||||
|
index = index,
|
||||||
|
queueSize = tracks.size,
|
||||||
|
isCurrent = index == currentIndex,
|
||||||
|
liked = track.id in likedTrackIds,
|
||||||
|
onClick = { onJumpTo(index) },
|
||||||
|
onToggleLike = { onToggleLike(track.id) },
|
||||||
|
onRemove = { onRemove(index) },
|
||||||
|
onMove = onMove,
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JumpToCurrentPill(
|
||||||
|
visible = currentIndex >= 0 && !currentVisible,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
private fun JumpToCurrentPill(
|
||||||
val highlight = if (isCurrent) {
|
visible: Boolean,
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
onClick: () -> Unit,
|
||||||
} else {
|
modifier: Modifier = Modifier,
|
||||||
Color.Transparent
|
) {
|
||||||
}
|
AnimatedVisibility(visible = visible, modifier = modifier) {
|
||||||
Row(
|
FilledTonalButton(onClick = onClick) {
|
||||||
modifier = Modifier
|
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
.fillMaxWidth()
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
.background(highlight)
|
Text("Jump to current")
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
if (isCurrent) {
|
|
||||||
Icon(
|
|
||||||
Lucide.Volume2,
|
|
||||||
contentDescription = "Now playing",
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = track.title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
val subtitle = queueSubtitle(track)
|
|
||||||
if (subtitle.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (track.durationSec > 0) {
|
|
||||||
Text(
|
|
||||||
text = formatDuration(track.durationSec),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "Artist · Album" — collapses gracefully when either is missing. */
|
|
||||||
private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, track.albumTitle)
|
|
||||||
.filter { it.isNotEmpty() }
|
|
||||||
.joinToString(" · ")
|
|
||||||
|
|
||||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
/** "N tracks · 12 min" header summary. */
|
||||||
|
private fun queueSummary(tracks: List<TrackRef>): String {
|
||||||
|
val minutes = tracks.sumOf { it.durationSec } / SECONDS_PER_MINUTE
|
||||||
|
val length = if (minutes >= MINUTES_PER_HOUR) {
|
||||||
|
"${minutes / MINUTES_PER_HOUR}h ${minutes % MINUTES_PER_HOUR}m"
|
||||||
|
} else {
|
||||||
|
"$minutes min"
|
||||||
|
}
|
||||||
|
val noun = if (tracks.size == 1) "track" else "tracks"
|
||||||
|
return "${tracks.size} $noun · $length"
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val SECONDS_PER_MINUTE = 60
|
||||||
|
private const val MINUTES_PER_HOUR = 60
|
||||||
|
|||||||
@@ -119,9 +119,9 @@ class PlaylistsRepository @Inject constructor(
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
||||||
playlistTrackDao.deleteByPlaylist(id)
|
playlistTrackDao.replacePlaylistTracks(
|
||||||
playlistTrackDao.upsertAll(
|
playlistId = id,
|
||||||
wire.tracks.mapNotNull { row ->
|
rows = wire.tracks.mapNotNull { row ->
|
||||||
row.trackId?.let { trackId ->
|
row.trackId?.let { trackId ->
|
||||||
CachedPlaylistTrackEntity(
|
CachedPlaylistTrackEntity(
|
||||||
playlistId = id,
|
playlistId = id,
|
||||||
|
|||||||
@@ -6,22 +6,27 @@ import com.fabledsword.minstrel.BuildConfig
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
import com.fabledsword.minstrel.update.data.UpdateRepository
|
import com.fabledsword.minstrel.update.data.UpdateRepository
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
import com.fabledsword.minstrel.update.data.isVersionNewer
|
import com.fabledsword.minstrel.update.data.isVersionNewer
|
||||||
|
import com.fabledsword.minstrel.update.data.message
|
||||||
|
import com.fabledsword.minstrel.update.data.stage
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One of three terminal states the Check-for-updates button surfaces.
|
* One of three terminal states the Check-for-updates button surfaces.
|
||||||
* `Idle` is the pre-check state; `Latest` means the installed build
|
* `Idle` is the pre-check state; `Latest` means the installed build
|
||||||
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
* matches or exceeds the server's bundled APK; `UpdateAvailable`
|
||||||
* surfaces an "Install vX.Y.Z" button that downloads + launches the
|
* surfaces an "Install vX.Y.Z" button that downloads the APK and
|
||||||
* system installer via [ApkInstaller].
|
* installs it via [ApkInstaller].
|
||||||
*/
|
*/
|
||||||
sealed interface UpdateCheckResult {
|
sealed interface UpdateCheckResult {
|
||||||
data object Idle : UpdateCheckResult
|
data object Idle : UpdateCheckResult
|
||||||
@@ -33,7 +38,7 @@ sealed interface UpdateCheckResult {
|
|||||||
data class AboutUiState(
|
data class AboutUiState(
|
||||||
val installedVersion: String = BuildConfig.VERSION_NAME,
|
val installedVersion: String = BuildConfig.VERSION_NAME,
|
||||||
val isChecking: Boolean = false,
|
val isChecking: Boolean = false,
|
||||||
val isInstalling: Boolean = false,
|
val installStage: InstallStage = InstallStage.IDLE,
|
||||||
val installMessage: String? = null,
|
val installMessage: String? = null,
|
||||||
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
val result: UpdateCheckResult = UpdateCheckResult.Idle,
|
||||||
)
|
)
|
||||||
@@ -43,9 +48,9 @@ data class AboutUiState(
|
|||||||
* [UpdateRepository.getLatest], compares versus the build's
|
* [UpdateRepository.getLatest], compares versus the build's
|
||||||
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
* VERSION_NAME via [isVersionNewer], and reports the terminal state.
|
||||||
* When an update is available, [install] downloads the APK via
|
* When an update is available, [install] downloads the APK via
|
||||||
* [ApkInstaller] and hands it to the system installer — routing the
|
* [ApkInstaller] and installs it — routing the user to the "install
|
||||||
* user to the "install unknown apps" settings page first when that
|
* unknown apps" settings page first when that permission hasn't been
|
||||||
* permission hasn't been granted.
|
* granted.
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AboutCardViewModel @Inject constructor(
|
class AboutCardViewModel @Inject constructor(
|
||||||
@@ -75,7 +80,7 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun install(info: UpdateInfo) {
|
fun install(info: UpdateInfo) {
|
||||||
if (internal.value.isInstalling) return
|
if (internal.value.installStage.isBusy()) return
|
||||||
if (!installer.canInstall()) {
|
if (!installer.canInstall()) {
|
||||||
installer.requestInstallPermission()
|
installer.requestInstallPermission()
|
||||||
internal.update {
|
internal.update {
|
||||||
@@ -84,21 +89,32 @@ class AboutCardViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
internal.update { it.copy(isInstalling = true, installMessage = null) }
|
internal.update {
|
||||||
runCatching { installer.downloadApk(info.apkUrl) }
|
it.copy(installStage = InstallStage.DOWNLOADING, installMessage = null)
|
||||||
.onSuccess { apk ->
|
}
|
||||||
installer.launchInstall(apk)
|
val apk = download(info.apkUrl)
|
||||||
internal.update { it.copy(isInstalling = false) }
|
if (apk != null) {
|
||||||
}
|
// The install half now suspends on the platform's verdict, so it
|
||||||
.onFailure { e ->
|
// gets its own stage — reporting "Downloading…" through it would
|
||||||
val why = ErrorCopy.fromThrowable(e)
|
// be a lie once a confirm dialog is on screen.
|
||||||
internal.update {
|
internal.update { it.copy(installStage = InstallStage.INSTALLING) }
|
||||||
it.copy(
|
val outcome = installer.install(apk)
|
||||||
isInstalling = false,
|
internal.update {
|
||||||
installMessage = "Couldn't download update: $why",
|
it.copy(installStage = outcome.stage(), installMessage = outcome.message())
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun download(apkUrl: String): File? =
|
||||||
|
runCatching { installer.downloadApk(apkUrl) }
|
||||||
|
.onFailure { e ->
|
||||||
|
internal.update {
|
||||||
|
it.copy(
|
||||||
|
installStage = InstallStage.ERROR,
|
||||||
|
installMessage = "Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import com.fabledsword.minstrel.nav.ServerUrl
|
|||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.theme.ThemeMode
|
import com.fabledsword.minstrel.theme.ThemeMode
|
||||||
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
import com.fabledsword.minstrel.theme.ThemePreferenceViewModel
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(
|
fun SettingsScreen(
|
||||||
@@ -381,7 +383,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
UpdateCheckLine(result = state.result)
|
UpdateCheckLine(result = state.result)
|
||||||
Button(
|
Button(
|
||||||
onClick = viewModel::checkForUpdates,
|
onClick = viewModel::checkForUpdates,
|
||||||
enabled = !state.isChecking && !state.isInstalling,
|
enabled = !state.isChecking && !state.installStage.isBusy(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (state.isChecking) {
|
if (state.isChecking) {
|
||||||
@@ -393,7 +395,7 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
if (available != null) {
|
if (available != null) {
|
||||||
InstallButton(
|
InstallButton(
|
||||||
version = available.info.version,
|
version = available.info.version,
|
||||||
isInstalling = state.isInstalling,
|
stage = state.installStage,
|
||||||
onClick = { viewModel.install(available.info) },
|
onClick = { viewModel.install(available.info) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -407,16 +409,22 @@ private fun UpdateControls(state: AboutUiState, viewModel: AboutCardViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InstallButton(version: String, isInstalling: Boolean, onClick: () -> Unit) {
|
private fun InstallButton(version: String, stage: InstallStage, onClick: () -> Unit) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = !isInstalling,
|
enabled = !stage.isBusy(),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (isInstalling) {
|
if (stage.isBusy()) {
|
||||||
ButtonSpinner()
|
ButtonSpinner()
|
||||||
}
|
}
|
||||||
Text(if (isInstalling) "Downloading…" else "Install $version")
|
Text(
|
||||||
|
when (stage) {
|
||||||
|
InstallStage.DOWNLOADING -> "Downloading…"
|
||||||
|
InstallStage.INSTALLING -> "Installing…"
|
||||||
|
else -> "Install $version"
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package com.fabledsword.minstrel.shared
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
// Once raised, the veil stays up at least this long. Without a floor a
|
||||||
|
// no-op refresh wipes on and straight back off, which reads as a glitch.
|
||||||
|
private const val DEFAULT_MIN_HOLD_MS = 900L
|
||||||
|
|
||||||
|
// The screen must stop changing for this long before the veil lowers.
|
||||||
|
// Every content change re-arms it, so a refresh that lands in stages
|
||||||
|
// (index → tile hydration → artwork) holds the veil across all of them.
|
||||||
|
private const val DEFAULT_QUIET_MS = 700L
|
||||||
|
|
||||||
|
// Hard ceiling on visibility. A refresh that never settles must not
|
||||||
|
// strand the user behind an opaque veil; the work itself is NOT capped.
|
||||||
|
private const val DEFAULT_MAX_HOLD_MS = 12_000L
|
||||||
|
|
||||||
|
// Attempts per session. Retrying behind the veil is the point: a pull
|
||||||
|
// that fails on the first try gets another go before the user sees
|
||||||
|
// anything, instead of the veil wiping off over unchanged content.
|
||||||
|
private const val DEFAULT_ATTEMPTS = 3
|
||||||
|
private const val DEFAULT_RETRY_BACKOFF_MS = 600L
|
||||||
|
|
||||||
|
/** Tunables for [UpdateVeilController]; defaults are the Home values. */
|
||||||
|
data class VeilTimings(
|
||||||
|
val minHoldMs: Long = DEFAULT_MIN_HOLD_MS,
|
||||||
|
val quietMs: Long = DEFAULT_QUIET_MS,
|
||||||
|
val maxHoldMs: Long = DEFAULT_MAX_HOLD_MS,
|
||||||
|
val attempts: Int = DEFAULT_ATTEMPTS,
|
||||||
|
val retryBackoffMs: Long = DEFAULT_RETRY_BACKOFF_MS,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A snapshot of everything that visibly moves on the veiled screen.
|
||||||
|
*
|
||||||
|
* @param contentKey any value whose equality tracks what's rendered — a
|
||||||
|
* change means the screen moved, and re-arms the quiet timer.
|
||||||
|
* @param hasContent true when real content (not a skeleton or an empty
|
||||||
|
* state) is on screen. The veil waits for this before raising: there's
|
||||||
|
* nothing to hide until there's something to hide.
|
||||||
|
* @param quiescent false while something is still landing (artwork
|
||||||
|
* loading, tiles hydrating). The veil will not lower until this is
|
||||||
|
* true, up to [VeilTimings.maxHoldMs].
|
||||||
|
*/
|
||||||
|
data class VeilSettleState(
|
||||||
|
val contentKey: Any?,
|
||||||
|
val hasContent: Boolean,
|
||||||
|
val quiescent: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What a finished session did, so callers can report it if they want. */
|
||||||
|
enum class VeilOutcome {
|
||||||
|
/** Content changed, and the veil covered the churn. */
|
||||||
|
CHANGED,
|
||||||
|
|
||||||
|
/** The refresh worked, but nothing on screen moved — already current. */
|
||||||
|
UNCHANGED,
|
||||||
|
|
||||||
|
/** Every attempt failed. */
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One session's result, plus whether a user explicitly asked for it.
|
||||||
|
*
|
||||||
|
* [userInitiated] is what lets a caller tell feedback from noise: a user
|
||||||
|
* who pulled to refresh is owed an answer even when the answer is "nothing
|
||||||
|
* changed", while the same outcome from a background check is noise.
|
||||||
|
*/
|
||||||
|
data class VeilSessionResult(
|
||||||
|
val outcome: VeilOutcome,
|
||||||
|
val userInitiated: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives an "updating" overlay from *observed content change and settling*
|
||||||
|
* rather than from a fixed delay.
|
||||||
|
*
|
||||||
|
* The problem this replaces: a veil held for `refresh().join() + 500ms`
|
||||||
|
* lowers while the screen is still moving, because finishing the network
|
||||||
|
* pull is nowhere near the end of the visible work — the pull writes id
|
||||||
|
* lists, then tiles hydrate one by one, then artwork loads. And a plain
|
||||||
|
* `isUpdating` Boolean set in a `finally` gets cleared by whichever of
|
||||||
|
* two overlapping refreshes finishes first, wiping the veil off mid-update
|
||||||
|
* (issue #2327).
|
||||||
|
*
|
||||||
|
* So instead: run [work], raise only if the content actually changes, then
|
||||||
|
* hold until [settleSignal] reports the screen has stopped changing for
|
||||||
|
* [VeilTimings.quietMs] AND is quiescent — bounded below by
|
||||||
|
* [VeilTimings.minHoldMs] so it can never flash, and above by
|
||||||
|
* [VeilTimings.maxHoldMs] so it can never strand.
|
||||||
|
*
|
||||||
|
* The raise is deliberately *reactive*: a refresh that returns what's
|
||||||
|
* already on screen — the common case on a launch over a warm cache —
|
||||||
|
* raises nothing at all, because a veil over an unchanged screen hides
|
||||||
|
* nothing and only delays first paint. The cost is that the veil arrives
|
||||||
|
* one emission after the change, so a single atomic content swap shows
|
||||||
|
* through; everything messier that follows it (tile hydration, then
|
||||||
|
* artwork) still lands behind the veil.
|
||||||
|
*
|
||||||
|
* Overlapping triggers extend the running session instead of racing it,
|
||||||
|
* so the veil stays up continuously rather than lowering and re-raising.
|
||||||
|
*
|
||||||
|
* Failure is quiet at this layer: [work] gets [VeilTimings.attempts] tries
|
||||||
|
* behind the veil, and if they all fail the veil simply wipes off over the
|
||||||
|
* cached content. Giving up ends only *this* session — it sets no latch and
|
||||||
|
* blocks nothing, so the caller's own recovery paths (reconnect re-pull,
|
||||||
|
* freshness sweeps, the next event, a manual pull) keep retrying afterwards
|
||||||
|
* exactly as before. Callers that want to surface a failure can do it from
|
||||||
|
* [onSessionEnd] instead.
|
||||||
|
*
|
||||||
|
* @param work one refresh attempt; returns true when it succeeded.
|
||||||
|
* @param shouldVeil sampled at session start — "is there cached content
|
||||||
|
* this refresh is about to overwrite?". False means a cold load, where
|
||||||
|
* a skeleton is the right affordance, and the work runs unveiled.
|
||||||
|
* @param onSessionEnd called once per finished session, on the controller's
|
||||||
|
* coroutine. Use it for user-facing feedback the veil itself can't give.
|
||||||
|
*/
|
||||||
|
class UpdateVeilController(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val settleSignal: Flow<VeilSettleState>,
|
||||||
|
private val shouldVeil: suspend () -> Boolean,
|
||||||
|
private val timings: VeilTimings = VeilTimings(),
|
||||||
|
private val onSessionEnd: (VeilSessionResult) -> Unit = {},
|
||||||
|
private val work: suspend () -> Boolean,
|
||||||
|
) {
|
||||||
|
private val visibleInternal = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** True while the veil should be drawn over the screen. */
|
||||||
|
val visible: StateFlow<Boolean> = visibleInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val finishedInternal = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments as each session ends. Lets a caller wait for "this
|
||||||
|
* refresh is done" without knowing whether a veil ever went up —
|
||||||
|
* a pull-to-refresh indicator needs exactly that, since an unchanged
|
||||||
|
* refresh never raises one.
|
||||||
|
*/
|
||||||
|
val finishedSessions: StateFlow<Int> = finishedInternal.asStateFlow()
|
||||||
|
|
||||||
|
// Conflated: a burst of triggers (reconnect + rebuild event arriving
|
||||||
|
// together) collapses into one follow-up pass, not a queue of them.
|
||||||
|
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||||
|
|
||||||
|
// Sticky across a conflated burst: conflation drops the older token, so
|
||||||
|
// the "a user asked for this" bit can't ride on it. If ANY coalesced
|
||||||
|
// trigger was the user's, the session still owes them an answer.
|
||||||
|
private val userAsked = AtomicBoolean(false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// One consumer, so sessions are serialised by construction: two
|
||||||
|
// triggers can never each own a piece of the veil's state.
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
requests.receive()
|
||||||
|
runSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for a refresh. Safe to call from any trigger at any rate —
|
||||||
|
* calls arriving during a session extend it rather than starting a
|
||||||
|
* competing one.
|
||||||
|
*
|
||||||
|
* @param userInitiated true when a person explicitly asked (pull to
|
||||||
|
* refresh, a Retry button), which is what [VeilSessionResult] carries
|
||||||
|
* through to [onSessionEnd].
|
||||||
|
*/
|
||||||
|
fun request(userInitiated: Boolean = false) {
|
||||||
|
if (userInitiated) userAsked.set(true)
|
||||||
|
requests.trySend(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runSession() {
|
||||||
|
// Sampled at both ends of the work: a trigger folded in mid-session
|
||||||
|
// (see [drainWork]) may have been the user's, and they're still owed
|
||||||
|
// an answer for it.
|
||||||
|
val askedAtStart = userAsked.getAndSet(false)
|
||||||
|
if (!shouldVeil()) {
|
||||||
|
// Cold load: the skeleton is the right affordance, so no veil.
|
||||||
|
// Succeeding here did change the screen — from nothing to
|
||||||
|
// something — so it reports CHANGED, never "already up to date".
|
||||||
|
val ok = drainWork()
|
||||||
|
finish(succeeded = ok, changed = ok, userInitiated = askedAtStart)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val raised = CompletableDeferred<Unit>()
|
||||||
|
val raiser = scope.launch { raiseWhenContentChanges(raised) }
|
||||||
|
// Floor and ceiling are measured from the raise, not the request,
|
||||||
|
// so a late raise still gets its full no-flash minimum.
|
||||||
|
val floor = scope.launch {
|
||||||
|
raised.await()
|
||||||
|
delay(timings.minHoldMs)
|
||||||
|
}
|
||||||
|
val ceiling = scope.launch {
|
||||||
|
raised.await()
|
||||||
|
delay(timings.maxHoldMs)
|
||||||
|
visibleInternal.value = false
|
||||||
|
}
|
||||||
|
var succeeded = false
|
||||||
|
try {
|
||||||
|
succeeded = drainWork()
|
||||||
|
// Always wait for the settle, never conditionally on `visible`:
|
||||||
|
// work that finishes without suspending would otherwise reach
|
||||||
|
// here before the raiser has been dispatched, tear the session
|
||||||
|
// down, and leave the churn uncovered. This wait is also what
|
||||||
|
// makes `raised.isCompleted` below a trustworthy "did anything
|
||||||
|
// change?" — a change landing just after the pull returns still
|
||||||
|
// gets seen.
|
||||||
|
withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() }
|
||||||
|
// Honour the no-flash minimum before lowering. Deliberately in
|
||||||
|
// the try and not the finally: on cancellation the scope is
|
||||||
|
// going away and nothing will render the veil, so the floor is
|
||||||
|
// pointless there — and a finally that suspends is a finally
|
||||||
|
// that can resist teardown.
|
||||||
|
if (raised.isCompleted) floor.join()
|
||||||
|
} finally {
|
||||||
|
raiser.cancel()
|
||||||
|
ceiling.cancel()
|
||||||
|
floor.cancel()
|
||||||
|
visibleInternal.value = false
|
||||||
|
finish(
|
||||||
|
succeeded = succeeded,
|
||||||
|
changed = raised.isCompleted,
|
||||||
|
userInitiated = askedAtStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises the veil the moment the screen's content differs from what was
|
||||||
|
* already on it — and never, if this refresh turns out to be a no-op.
|
||||||
|
*
|
||||||
|
* The baseline is the first state that HAS content, not simply the first
|
||||||
|
* state: over a warm cache the cached rows paint a moment after the
|
||||||
|
* session starts, and treating that first paint as "a change" would veil
|
||||||
|
* every launch, which is the whole thing this avoids.
|
||||||
|
*/
|
||||||
|
private suspend fun raiseWhenContentChanges(raised: CompletableDeferred<Unit>) {
|
||||||
|
val baseline = settleSignal.first { it.hasContent }
|
||||||
|
settleSignal.first { it.hasContent && it.contentKey != baseline.contentKey }
|
||||||
|
visibleInternal.value = true
|
||||||
|
raised.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(succeeded: Boolean, changed: Boolean, userInitiated: Boolean) {
|
||||||
|
val outcome = when {
|
||||||
|
!succeeded -> VeilOutcome.FAILED
|
||||||
|
changed -> VeilOutcome.CHANGED
|
||||||
|
else -> VeilOutcome.UNCHANGED
|
||||||
|
}
|
||||||
|
onSessionEnd(
|
||||||
|
VeilSessionResult(
|
||||||
|
outcome = outcome,
|
||||||
|
// Fold in a mid-session request from the user.
|
||||||
|
userInitiated = userInitiated || userAsked.getAndSet(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finishedInternal.update { it + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the refresh eventually succeeded. */
|
||||||
|
private suspend fun drainWork(): Boolean {
|
||||||
|
var succeeded: Boolean
|
||||||
|
do {
|
||||||
|
succeeded = runWorkWithRetries()
|
||||||
|
// A trigger that arrived mid-session gets folded into this one.
|
||||||
|
} while (requests.tryReceive().isSuccess)
|
||||||
|
return succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runWorkWithRetries(): Boolean {
|
||||||
|
repeat(timings.attempts) { attempt ->
|
||||||
|
if (work()) return true
|
||||||
|
if (attempt < timings.attempts - 1) {
|
||||||
|
delay(timings.retryBackoffMs * (attempt + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends until the screen has been unchanged for
|
||||||
|
* [VeilTimings.quietMs] and reports itself quiescent.
|
||||||
|
*
|
||||||
|
* `debounce` is what makes this hold across a staged update: every
|
||||||
|
* change restarts the window, so the veil lowers only once emissions
|
||||||
|
* actually stop. `first { quiescent }` then rejects a quiet-but-
|
||||||
|
* still-loading moment and waits for the next lull.
|
||||||
|
*/
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
private suspend fun awaitSettled() {
|
||||||
|
settleSignal
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.debounce(timings.quietMs)
|
||||||
|
.first { it.quiescent }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the cover-art loads that are currently in flight, so a
|
||||||
|
* screen-level overlay can wait for the artwork to actually land instead
|
||||||
|
* of guessing with a fixed delay.
|
||||||
|
*
|
||||||
|
* Artwork is the most visible pop-in on Home: a tile can be fully
|
||||||
|
* hydrated (title, artist, counts all present) and still snap its cover
|
||||||
|
* in a second later, which is exactly the churn the "Updating your
|
||||||
|
* mixes…" veil exists to hide. The refresh coroutine can't see that —
|
||||||
|
* it finishes long before Coil does — so the composition reports it
|
||||||
|
* upward here instead.
|
||||||
|
*
|
||||||
|
* [ServerImage] reports into whatever tracker it finds in
|
||||||
|
* [LocalArtSettleTracker], which means every art surface in the app
|
||||||
|
* participates for free. Only *composed* images are counted, so a
|
||||||
|
* LazyRow's off-screen tiles are correctly ignored — the count tracks
|
||||||
|
* the pop-in a user can actually see.
|
||||||
|
*
|
||||||
|
* Provide one per screen that needs it (typically owned by the
|
||||||
|
* screen's ViewModel so its refresh logic can read [inFlight]):
|
||||||
|
*
|
||||||
|
* CompositionLocalProvider(LocalArtSettleTracker provides vm.artTracker) { ... }
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class ArtSettleTracker {
|
||||||
|
private val inFlightInternal = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many on-screen images are still loading. Zero means the
|
||||||
|
* artwork has settled — every composed cover has either drawn or
|
||||||
|
* failed to a fallback.
|
||||||
|
*/
|
||||||
|
val inFlight: StateFlow<Int> = inFlightInternal.asStateFlow()
|
||||||
|
|
||||||
|
fun begin() {
|
||||||
|
inFlightInternal.update { it + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun end() {
|
||||||
|
// Floor at zero: a decrement can outlive its increment when a
|
||||||
|
// tile is disposed mid-load and the count must not go negative
|
||||||
|
// and wedge "settled" off forever.
|
||||||
|
inFlightInternal.update { (it - 1).coerceAtLeast(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tracker [ServerImage] reports load state to, or null on screens
|
||||||
|
* that don't care (the default) — reporting is then a no-op.
|
||||||
|
*/
|
||||||
|
val LocalArtSettleTracker = staticCompositionLocalOf<ArtSettleTracker?> { null }
|
||||||
@@ -23,8 +23,12 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
|||||||
* ServerImage + fallback structure was identical at all three sites.
|
* ServerImage + fallback structure was identical at all three sites.
|
||||||
*
|
*
|
||||||
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
||||||
* (e.g. the `VariantPill` system-playlist label) — callers can use
|
* (e.g. the `VariantPill` system-playlist label, the play button) —
|
||||||
* `Modifier.align(...)` inside it.
|
* callers can use `Modifier.align(...)` inside it. The overlay sits on
|
||||||
|
* an outer, UN-clipped box so corner-anchored widgets are not cut off by
|
||||||
|
* [shape]: a `BottomEnd` play button on a `CircleShape` avatar falls in
|
||||||
|
* the square's corner, outside the circle, and would otherwise be clipped
|
||||||
|
* away. Only the artwork + background are clipped to [shape].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CoverTile(
|
fun CoverTile(
|
||||||
@@ -38,18 +42,23 @@ fun CoverTile(
|
|||||||
overlay: @Composable BoxScope.() -> Unit = {},
|
overlay: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier.size(size),
|
||||||
.size(size)
|
|
||||||
.clip(shape)
|
|
||||||
.background(background),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
ServerImage(
|
Box(
|
||||||
url = url,
|
modifier = Modifier
|
||||||
contentDescription = contentDescription,
|
.fillMaxSize()
|
||||||
modifier = Modifier.fillMaxSize(),
|
.clip(shape)
|
||||||
|
.background(background),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
fallback()
|
ServerImage(
|
||||||
|
url = url,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
overlay()
|
overlay()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.compose.AsyncImagePainter
|
import coil3.compose.AsyncImagePainter
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
|
|
||||||
|
// The fallback fades out as the artwork crossfades in (Coil's crossfade is
|
||||||
|
// configured globally on the ImageLoader in MinstrelApplication). Matching
|
||||||
|
// durations makes the swap read as one cross-dissolve; without the fade the
|
||||||
|
// placeholder icon vanished a frame before the cover appeared, which is the
|
||||||
|
// "art popping in" the Home veil exists to hide (issue #2327).
|
||||||
|
private const val FALLBACK_FADE_MS = 220
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||||
* is blank/unresolvable, while the image is still loading, and when the
|
* 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,
|
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||||
* which the "You might like" row hits often).
|
* which the "You might like" row hits often).
|
||||||
|
*
|
||||||
|
* In-flight loads are reported to [LocalArtSettleTracker] when a screen
|
||||||
|
* provides one, so a screen-level overlay can wait for artwork to land
|
||||||
|
* instead of guessing with a fixed delay.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerImage(
|
fun ServerImage(
|
||||||
@@ -40,6 +55,23 @@ fun ServerImage(
|
|||||||
var state by remember(resolved) {
|
var state by remember(resolved) {
|
||||||
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
||||||
}
|
}
|
||||||
|
// Empty counts as loading: it's the pre-request state, so treating it
|
||||||
|
// as settled would let a screen overlay lower before Coil even starts.
|
||||||
|
val loading = state is AsyncImagePainter.State.Empty ||
|
||||||
|
state is AsyncImagePainter.State.Loading
|
||||||
|
val tracker = LocalArtSettleTracker.current
|
||||||
|
DisposableEffect(tracker, loading) {
|
||||||
|
if (loading) tracker?.begin()
|
||||||
|
// Balanced by construction: the effect re-runs when `loading` flips
|
||||||
|
// (decrement, then no re-increment) and disposes when a tile leaves
|
||||||
|
// the composition mid-load (scrolled away).
|
||||||
|
onDispose { if (loading) tracker?.end() }
|
||||||
|
}
|
||||||
|
val fallbackAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (loading || state is AsyncImagePainter.State.Error) 1f else 0f,
|
||||||
|
animationSpec = tween(FALLBACK_FADE_MS),
|
||||||
|
label = "art-fallback",
|
||||||
|
)
|
||||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = resolved,
|
model = resolved,
|
||||||
@@ -48,10 +80,10 @@ fun ServerImage(
|
|||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
onState = { state = it },
|
onState = { state = it },
|
||||||
)
|
)
|
||||||
if (state is AsyncImagePainter.State.Loading ||
|
if (fallbackAlpha > 0f) {
|
||||||
state is AsyncImagePainter.State.Error
|
Box(Modifier.alpha(fallbackAlpha), contentAlignment = Alignment.Center) {
|
||||||
) {
|
fallback()
|
||||||
fallback()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.content.Intent
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -17,27 +16,26 @@ import javax.inject.Inject
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
private const val APK_FILENAME = "minstrel-update.apk"
|
private const val APK_FILENAME = "minstrel-update.apk"
|
||||||
private const val APK_MIME = "application/vnd.android.package-archive"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads the server-bundled APK and hands it to Android's package
|
* Downloads the server-bundled APK and installs it over ourselves.
|
||||||
* installer. Mirrors Flutter's `update/installer.dart` — the native
|
|
||||||
* side that the Flutter MethodChannel delegated to.
|
|
||||||
*
|
*
|
||||||
* The download goes through the shared [OkHttpClient] so it inherits
|
* The download goes through the shared [OkHttpClient] so it inherits
|
||||||
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
* the auth cookie + the BaseUrlInterceptor host rewrite (apkUrl is
|
||||||
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
* server-relative, e.g. `/api/client/apk`). The APK lands in the
|
||||||
* cache dir, exposed to the system installer via the app's
|
* cache dir; [SelfUpdateSession] streams it from there into a
|
||||||
* FileProvider content:// URI.
|
* [android.content.pm.PackageInstaller] session.
|
||||||
*
|
*
|
||||||
* On Android O+ the user must have granted "install unknown apps"
|
* On Android O+ the user must have granted "install unknown apps"
|
||||||
* for Minstrel; [canInstall] reports it and [requestInstallPermission]
|
* for Minstrel; [canInstall] reports it and [requestInstallPermission]
|
||||||
* opens the relevant settings screen.
|
* opens the relevant settings screen. That grant is still required with
|
||||||
|
* the session API — silent *updates* don't imply silent *permission*.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class ApkInstaller @Inject constructor(
|
class ApkInstaller @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
|
private val session: SelfUpdateSession,
|
||||||
) {
|
) {
|
||||||
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
suspend fun downloadApk(apkUrl: String): File = withContext(Dispatchers.IO) {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
@@ -61,19 +59,13 @@ class ApkInstaller @Inject constructor(
|
|||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||||
context.packageManager.canRequestPackageInstalls()
|
context.packageManager.canRequestPackageInstalls()
|
||||||
|
|
||||||
/** Hand the downloaded APK to the system installer's confirm dialog. */
|
/**
|
||||||
fun launchInstall(apk: File) {
|
* Install [apk] over ourselves, suspending until the platform decides.
|
||||||
val uri: Uri = FileProvider.getUriForFile(
|
*
|
||||||
context,
|
* Note for callers: on a successful silent install this never returns —
|
||||||
"${context.packageName}.fileprovider",
|
* the process is replaced. Don't treat the absence of a verdict as failure.
|
||||||
apk,
|
*/
|
||||||
)
|
suspend fun install(apk: File): InstallOutcome = session.run(apk)
|
||||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, APK_MIME)
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Open the "install unknown apps" settings page for Minstrel. */
|
/** Open the "install unknown apps" settings page for Minstrel. */
|
||||||
fun requestInstallPermission() {
|
fun requestInstallPermission() {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal verdict from the platform on a self-update install (#2438).
|
||||||
|
*
|
||||||
|
* The old `ACTION_VIEW` handoff had no verdict at all — we fired an intent and
|
||||||
|
* assumed. A [PackageInstaller][android.content.pm.PackageInstaller] session
|
||||||
|
* reports back, so "declined" and "failed" stop looking identical.
|
||||||
|
*/
|
||||||
|
sealed interface InstallOutcome {
|
||||||
|
/**
|
||||||
|
* The platform completed the install.
|
||||||
|
*
|
||||||
|
* Rarely observed on a self-update: our process is replaced the moment the
|
||||||
|
* new APK lands, so the coroutine awaiting this usually dies before it
|
||||||
|
* resumes. Modelled anyway — silently relying on being killed would make
|
||||||
|
* the success path invisible to anyone reading this.
|
||||||
|
*/
|
||||||
|
data object Installed : InstallOutcome
|
||||||
|
|
||||||
|
/** The user declined the platform's confirm dialog. Not an error. */
|
||||||
|
data object Cancelled : InstallOutcome
|
||||||
|
|
||||||
|
/** The platform refused. [reason] is its own message, where it gave one. */
|
||||||
|
data class Failed(val reason: String?) : InstallOutcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an install has got to, for the two surfaces that show it: the shell's
|
||||||
|
* [UpdateBanner][com.fabledsword.minstrel.update.ui.UpdateBanner] and the
|
||||||
|
* Settings About card.
|
||||||
|
*
|
||||||
|
* DOWNLOADING and INSTALLING are deliberately distinct. They used to be one
|
||||||
|
* state because the install half was fire-and-forget and took no time from our
|
||||||
|
* side; now that we await the platform's verdict, collapsing them would leave
|
||||||
|
* the UI claiming "Downloading…" through an install that can sit on a confirm
|
||||||
|
* dialog indefinitely.
|
||||||
|
*/
|
||||||
|
enum class InstallStage { IDLE, DOWNLOADING, INSTALLING, ERROR }
|
||||||
|
|
||||||
|
/** True while an install is underway and a second tap should do nothing. */
|
||||||
|
fun InstallStage.isBusy(): Boolean =
|
||||||
|
this == InstallStage.DOWNLOADING || this == InstallStage.INSTALLING
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stage an outcome lands the UI in. A cancelled install returns to IDLE
|
||||||
|
* rather than ERROR — the user chose it, so presenting it as a failure would
|
||||||
|
* be a lie with a red tint.
|
||||||
|
*/
|
||||||
|
fun InstallOutcome.stage(): InstallStage = when (this) {
|
||||||
|
InstallOutcome.Installed, InstallOutcome.Cancelled -> InstallStage.IDLE
|
||||||
|
is InstallOutcome.Failed -> InstallStage.ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-facing copy for an outcome; null when there is nothing worth saying.
|
||||||
|
*
|
||||||
|
* Lives beside the outcome rather than in either UI package because two
|
||||||
|
* separate screens surface the same verdicts and must not drift — the same
|
||||||
|
* reasoning that puts [ErrorCopy][com.fabledsword.minstrel.api.ErrorCopy]
|
||||||
|
* outside the UI layer.
|
||||||
|
*/
|
||||||
|
fun InstallOutcome.message(): String? = when (this) {
|
||||||
|
InstallOutcome.Installed -> null
|
||||||
|
InstallOutcome.Cancelled -> "Update cancelled."
|
||||||
|
is InstallOutcome.Failed -> reason?.let { "Couldn't install update: $it" }
|
||||||
|
?: "Couldn't install update."
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.fabledsword.minstrel.update.data
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.IntentSender
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
|
private const val STAGED_APK_NAME = "minstrel-update"
|
||||||
|
|
||||||
|
/** Whole-file write: openWrite takes a Long offset, and Kotlin won't widen 0. */
|
||||||
|
private const val WRITE_FROM_START = 0L
|
||||||
|
|
||||||
|
/** Our own broadcast, delivered by the platform via the session's IntentSender. */
|
||||||
|
private const val RESULT_ACTION = "com.fabledsword.minstrel.INSTALL_RESULT"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs an APK over ourselves through a [PackageInstaller] session (#2438).
|
||||||
|
*
|
||||||
|
* Split from [ApkInstaller] because the two halves are different work — one
|
||||||
|
* speaks HTTP, the other speaks to the package manager — and the session half
|
||||||
|
* carries a receiver, a PendingIntent and version-gated params that would
|
||||||
|
* crowd the downloader out of its own file.
|
||||||
|
*
|
||||||
|
* ## Why a session, rather than the ACTION_VIEW intent this replaced
|
||||||
|
*
|
||||||
|
* Two reasons, and the second is the one that matters to users.
|
||||||
|
*
|
||||||
|
* The old path fired `ACTION_VIEW` at an `application/vnd.android.package-archive`
|
||||||
|
* URI and hoped. It could not report an outcome, so a failed install and a
|
||||||
|
* user who declined looked identical — see [InstallOutcome].
|
||||||
|
*
|
||||||
|
* More importantly, a session is where the platform lets a self-updater say it
|
||||||
|
* is one. [PackageInstaller.SessionParams.setRequireUserAction] with
|
||||||
|
* `USER_ACTION_NOT_REQUIRED`, paired with the `UPDATE_PACKAGES_WITHOUT_USER_ACTION`
|
||||||
|
* manifest permission, is the sanctioned way to update with **no dialog at
|
||||||
|
* all**. The platform grants that when all of: the installer opts in (here),
|
||||||
|
* the installed app targets API 29+ (we're on 36), the installer holds the
|
||||||
|
* permission (we do), and the target is the installer itself or something it
|
||||||
|
* first installed (we are updating ourselves). All four hold.
|
||||||
|
*
|
||||||
|
* ## What is deliberately absent
|
||||||
|
*
|
||||||
|
* No `setRequestUpdateOwnership(true)`. It reads like the right declaration for
|
||||||
|
* a self-updater and it is not: ownership can only be claimed on **initial**
|
||||||
|
* installation — setting it on an update is documented as a no-op — and it also
|
||||||
|
* wants the privileged `ENFORCE_UPDATE_OWNERSHIP` permission. It exists for app
|
||||||
|
* stores claiming the apps they install, not for an app updating itself.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SelfUpdateSession @Inject constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Stage [apk] and hand it to the platform, suspending until a terminal
|
||||||
|
* verdict arrives.
|
||||||
|
*
|
||||||
|
* Never returns on the happy path when the install is silent: the platform
|
||||||
|
* replaces this process the moment the new APK lands, so the coroutine dies
|
||||||
|
* rather than resuming. Callers must treat that as success, not a hang.
|
||||||
|
*/
|
||||||
|
suspend fun run(apk: File): InstallOutcome {
|
||||||
|
val staged = withContext(Dispatchers.IO) { runCatching { stage(apk) } }
|
||||||
|
return staged.fold(
|
||||||
|
onSuccess = { sessionId -> awaitCommit(sessionId) },
|
||||||
|
onFailure = { InstallOutcome.Failed(it.message) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open a session, stream the APK in, return the session id. */
|
||||||
|
private fun stage(apk: File): Int {
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
val sessionId = installer.createSession(newParams())
|
||||||
|
installer.openSession(sessionId).use { session ->
|
||||||
|
session.openWrite(STAGED_APK_NAME, WRITE_FROM_START, apk.length()).use { sink ->
|
||||||
|
apk.inputStream().use { source -> source.copyTo(sink) }
|
||||||
|
// fsync before the session closes: the platform validates the
|
||||||
|
// staged bytes at commit, and buffered tail bytes read as a
|
||||||
|
// truncated APK.
|
||||||
|
session.fsync(sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explicit `params.` receivers rather than an apply {} block: lintVitalRelease
|
||||||
|
// runs on assembleRelease, and NewApi is easier for it to reason about when
|
||||||
|
// the guarded call has a named receiver instead of an implicit one.
|
||||||
|
private fun newParams(): PackageInstaller.SessionParams {
|
||||||
|
val params = PackageInstaller.SessionParams(
|
||||||
|
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
|
||||||
|
)
|
||||||
|
params.setAppPackageName(context.packageName)
|
||||||
|
params.setInstallReason(PackageManager.INSTALL_REASON_USER)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
// The whole point of this class. Pre-S there is no such API, so the
|
||||||
|
// confirm dialog is unavoidable there — degrade, don't fail.
|
||||||
|
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit the session and wait for the platform to report back.
|
||||||
|
*
|
||||||
|
* A pending-user-action status is *not* terminal — the platform is asking us
|
||||||
|
* to show its dialog, and the real verdict arrives in a second broadcast
|
||||||
|
* once the user decides. So the receiver stays registered across it.
|
||||||
|
*/
|
||||||
|
private suspend fun awaitCommit(sessionId: Int): InstallOutcome =
|
||||||
|
suspendCancellableCoroutine { continuation ->
|
||||||
|
val installer = context.packageManager.packageInstaller
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(unused: Context, intent: Intent) {
|
||||||
|
val status = intent.getIntExtra(
|
||||||
|
PackageInstaller.EXTRA_STATUS,
|
||||||
|
PackageInstaller.STATUS_FAILURE,
|
||||||
|
)
|
||||||
|
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
|
||||||
|
confirmWithUser(intent)
|
||||||
|
} else {
|
||||||
|
context.unregisterReceiver(this)
|
||||||
|
val why = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
if (continuation.isActive) continuation.resume(outcomeOf(status, why))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ContextCompat.registerReceiver(
|
||||||
|
context,
|
||||||
|
receiver,
|
||||||
|
IntentFilter(RESULT_ACTION),
|
||||||
|
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||||
|
)
|
||||||
|
continuation.invokeOnCancellation {
|
||||||
|
// Stop listening, but deliberately do NOT abandon the session.
|
||||||
|
// Cancellation here means our caller's scope died — the user
|
||||||
|
// navigated away, or the VM cleared — and by this point the
|
||||||
|
// session is already committed. The user asked for this install;
|
||||||
|
// killing it because nobody is watching the banner any more
|
||||||
|
// would be the wrong reading of their intent.
|
||||||
|
runCatching { context.unregisterReceiver(receiver) }
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
installer.openSession(sessionId).use { it.commit(resultSender(sessionId)) }
|
||||||
|
}.onFailure { error ->
|
||||||
|
// Resuming normally means invokeOnCancellation never fires, so
|
||||||
|
// clean up the staged session here or it sits until it expires.
|
||||||
|
runCatching { context.unregisterReceiver(receiver) }
|
||||||
|
runCatching { installer.abandonSession(sessionId) }
|
||||||
|
if (continuation.isActive) {
|
||||||
|
continuation.resume(InstallOutcome.Failed(error.message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resultSender(sessionId: Int): IntentSender {
|
||||||
|
// Scoped to our own package so the broadcast can't be answered elsewhere.
|
||||||
|
val intent = Intent(RESULT_ACTION).setPackage(context.packageName)
|
||||||
|
var flags = PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
// The platform writes its status extras into this intent, so it has
|
||||||
|
// to stay mutable — FLAG_IMMUTABLE would arrive with none of them.
|
||||||
|
flags = flags or PendingIntent.FLAG_MUTABLE
|
||||||
|
}
|
||||||
|
// Session id as the request code keeps concurrent sessions from
|
||||||
|
// colliding on FLAG_UPDATE_CURRENT.
|
||||||
|
return PendingIntent.getBroadcast(context, sessionId, intent, flags).intentSender
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the platform's own confirm dialog, which arrives as an extra.
|
||||||
|
*
|
||||||
|
* The system-app check is not ceremony. Below API 34 a dynamically
|
||||||
|
* registered receiver cannot declare itself unexported, so another app on
|
||||||
|
* the device can broadcast [RESULT_ACTION] at us — and calling
|
||||||
|
* `startActivity` on an attacker-supplied extra would hand it whatever we
|
||||||
|
* can reach. The genuine confirm activity belongs to the platform
|
||||||
|
* installer, so demanding a system component costs the real path nothing.
|
||||||
|
*/
|
||||||
|
private fun confirmWithUser(result: Intent) {
|
||||||
|
val pending = IntentCompat.getParcelableExtra(
|
||||||
|
result,
|
||||||
|
Intent.EXTRA_INTENT,
|
||||||
|
Intent::class.java,
|
||||||
|
) ?: return
|
||||||
|
if (isPlatformActivity(pending)) {
|
||||||
|
context.startActivity(pending.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isPlatformActivity(intent: Intent): Boolean {
|
||||||
|
val flags = intent.resolveActivityInfo(context.packageManager, 0)
|
||||||
|
?.applicationInfo
|
||||||
|
?.flags
|
||||||
|
?: 0
|
||||||
|
val systemFlags = ApplicationInfo.FLAG_SYSTEM or ApplicationInfo.FLAG_UPDATED_SYSTEM_APP
|
||||||
|
return (flags and systemFlags) != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun outcomeOf(status: Int, message: String?): InstallOutcome = when (status) {
|
||||||
|
PackageInstaller.STATUS_SUCCESS -> InstallOutcome.Installed
|
||||||
|
PackageInstaller.STATUS_FAILURE_ABORTED -> InstallOutcome.Cancelled
|
||||||
|
else -> InstallOutcome.Failed(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ import com.composables.icons.lucide.Download
|
|||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.X
|
import com.composables.icons.lucide.X
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell-level soft banner that nudges an available update. Renders
|
* Shell-level soft banner that nudges an available update. Renders
|
||||||
@@ -79,7 +81,7 @@ private fun BannerBody(
|
|||||||
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
.padding(start = 16.dp, top = 8.dp, end = 4.dp, bottom = 8.dp),
|
||||||
) {
|
) {
|
||||||
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
BannerRow(info = info, stage = stage, onInstall = onInstall, onDismiss = onDismiss)
|
||||||
if (stage == InstallStage.DOWNLOADING) {
|
if (stage.isBusy()) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -124,8 +126,17 @@ private fun BannerRow(
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
TextButton(onClick = onInstall, enabled = stage != InstallStage.DOWNLOADING) {
|
TextButton(onClick = onInstall, enabled = !stage.isBusy()) {
|
||||||
Text(if (stage == InstallStage.DOWNLOADING) "Installing…" else "Install")
|
// Downloading and installing are separate words because they're now
|
||||||
|
// separate waits — the install half suspends on the platform, which
|
||||||
|
// may be sitting on a confirm dialog.
|
||||||
|
Text(
|
||||||
|
when (stage) {
|
||||||
|
InstallStage.DOWNLOADING -> "Downloading…"
|
||||||
|
InstallStage.INSTALLING -> "Installing…"
|
||||||
|
else -> "Install"
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
IconButton(onClick = onDismiss) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.models.UpdateInfo
|
import com.fabledsword.minstrel.models.UpdateInfo
|
||||||
import com.fabledsword.minstrel.update.data.ApkInstaller
|
import com.fabledsword.minstrel.update.data.ApkInstaller
|
||||||
|
import com.fabledsword.minstrel.update.data.InstallStage
|
||||||
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
import com.fabledsword.minstrel.update.data.UpdateBannerController
|
||||||
|
import com.fabledsword.minstrel.update.data.isBusy
|
||||||
|
import com.fabledsword.minstrel.update.data.message
|
||||||
|
import com.fabledsword.minstrel.update.data.stage
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -13,13 +17,11 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|
||||||
/** Install lifecycle for the banner's Install button. */
|
|
||||||
enum class InstallStage { IDLE, DOWNLOADING, ERROR }
|
|
||||||
|
|
||||||
data class UpdateBannerUiState(
|
data class UpdateBannerUiState(
|
||||||
val info: UpdateInfo? = null,
|
val info: UpdateInfo? = null,
|
||||||
val stage: InstallStage = InstallStage.IDLE,
|
val stage: InstallStage = InstallStage.IDLE,
|
||||||
@@ -28,9 +30,9 @@ data class UpdateBannerUiState(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
* Thin VM over [UpdateBannerController]. Surfaces the available update
|
||||||
* and runs the download → system-install handoff via [ApkInstaller],
|
* and runs the download → install handoff via [ApkInstaller], mirroring
|
||||||
* mirroring the About card's flow (route to "install unknown apps"
|
* the About card's flow (route to "install unknown apps" settings first
|
||||||
* settings first when the permission is missing).
|
* when the permission is missing).
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class UpdateBannerViewModel @Inject constructor(
|
class UpdateBannerViewModel @Inject constructor(
|
||||||
@@ -38,7 +40,7 @@ class UpdateBannerViewModel @Inject constructor(
|
|||||||
private val installer: ApkInstaller,
|
private val installer: ApkInstaller,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val installState = MutableStateFlow(IdleInstall)
|
private val installState = MutableStateFlow(InstallSnapshot(InstallStage.IDLE, null))
|
||||||
|
|
||||||
val uiState: StateFlow<UpdateBannerUiState> =
|
val uiState: StateFlow<UpdateBannerUiState> =
|
||||||
combine(controller.available, installState) { info, install ->
|
combine(controller.available, installState) { info, install ->
|
||||||
@@ -52,7 +54,7 @@ class UpdateBannerViewModel @Inject constructor(
|
|||||||
fun dismiss(version: String) = controller.dismiss(version)
|
fun dismiss(version: String) = controller.dismiss(version)
|
||||||
|
|
||||||
fun install(info: UpdateInfo) {
|
fun install(info: UpdateInfo) {
|
||||||
if (installState.value.stage == InstallStage.DOWNLOADING) return
|
if (installState.value.stage.isBusy()) return
|
||||||
if (!installer.canInstall()) {
|
if (!installer.canInstall()) {
|
||||||
installer.requestInstallPermission()
|
installer.requestInstallPermission()
|
||||||
installState.value = InstallSnapshot(
|
installState.value = InstallSnapshot(
|
||||||
@@ -63,21 +65,28 @@ class UpdateBannerViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
installState.value = InstallSnapshot(InstallStage.DOWNLOADING, null)
|
||||||
runCatching { installer.downloadApk(info.apkUrl) }
|
val apk = download(info.apkUrl)
|
||||||
.onSuccess { apk ->
|
if (apk != null) {
|
||||||
installer.launchInstall(apk)
|
// Await the platform's verdict rather than firing an intent and
|
||||||
installState.value = IdleInstall
|
// assuming it worked. On a silent install this suspends until
|
||||||
}
|
// the process is replaced, so the line below is only reached
|
||||||
.onFailure { e ->
|
// when the install did NOT simply succeed.
|
||||||
installState.value = InstallSnapshot(
|
installState.value = InstallSnapshot(InstallStage.INSTALLING, null)
|
||||||
InstallStage.ERROR,
|
val outcome = installer.install(apk)
|
||||||
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
installState.value = InstallSnapshot(outcome.stage(), outcome.message())
|
||||||
)
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun download(apkUrl: String): File? =
|
||||||
|
runCatching { installer.downloadApk(apkUrl) }
|
||||||
|
.onFailure { e ->
|
||||||
|
installState.value = InstallSnapshot(
|
||||||
|
InstallStage.ERROR,
|
||||||
|
"Couldn't download update: ${ErrorCopy.fromThrowable(e)}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
private data class InstallSnapshot(val stage: InstallStage, val message: String?)
|
||||||
|
|
||||||
private val IdleInstall = InstallSnapshot(InstallStage.IDLE, null)
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Adaptive icon (API 26+). Before this the app shipped legacy bitmaps only,
|
||||||
|
so modern launchers letterboxed the square instead of masking it to the
|
||||||
|
device's icon shape. The foreground PNGs are drawn on a 108dp canvas with
|
||||||
|
the mark inside the 66dp safe zone, so no mask can clip it. -->
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Obsidian. The adaptive icon's plate; chosen over the raised-surface
|
||||||
|
iron because the accent note only clears the 3:1 graphics contrast
|
||||||
|
threshold against this darker value (3.04:1 vs 2.70:1). -->
|
||||||
|
<color name="ic_launcher_background">#14171A</color>
|
||||||
|
</resources>
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<paths>
|
|
||||||
<!-- The downloaded update APK lives in the app cache dir; the
|
|
||||||
FileProvider exposes just that directory to the system
|
|
||||||
installer via a content:// URI. -->
|
|
||||||
<cache-path
|
|
||||||
name="updates"
|
|
||||||
path="." />
|
|
||||||
</paths>
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!--
|
||||||
|
Replaces a bare android:usesCleartextTraffic="true" on <application> (#2439).
|
||||||
|
|
||||||
|
Cleartext is still permitted app-wide, and it has to be. Two independent
|
||||||
|
reasons, neither of which can be narrowed to a domain list:
|
||||||
|
|
||||||
|
1. The Minstrel server's host is entered by the user at runtime. Plenty of
|
||||||
|
self-hosters run it over plain HTTP on a LAN; refusing that would break
|
||||||
|
real installs rather than secure anyone.
|
||||||
|
|
||||||
|
2. UPnP / DLNA / Sonos. Device-description and SOAP control URLs arrive in
|
||||||
|
SSDP responses at runtime and are plain HTTP essentially without
|
||||||
|
exception — see player/output/upnp/{UpnpDiscoveryController,SoapClient}.
|
||||||
|
|
||||||
|
A <domain-config> would be the way to scope this, but it matches literal
|
||||||
|
hostnames rather than CIDR ranges, and both sets of hosts above are unknowable
|
||||||
|
until runtime. So a permissive base-config is an honest description of our
|
||||||
|
situation — the gain over the manifest attribute is that the reasoning now
|
||||||
|
lives somewhere, and there is one place to tighten if a future settings screen
|
||||||
|
can distinguish a LAN server from a WAN one.
|
||||||
|
|
||||||
|
Worth stating because it looks worse than it is: this is NOT a tamper risk for
|
||||||
|
the in-app updater. An APK altered in transit and re-signed is rejected by the
|
||||||
|
platform as a signature mismatch on update, so the boundary there is enforced
|
||||||
|
regardless of transport.
|
||||||
|
|
||||||
|
Trust anchors are deliberately left at the platform default (system CAs only).
|
||||||
|
Adding <certificates src="user" /> would let self-hosters use HTTPS with their
|
||||||
|
own private CA — attractive for this product, and what Mihon does — but it
|
||||||
|
also makes the app trust every CA on the device, including a corporate MITM
|
||||||
|
proxy. That's an operator decision, not a default worth assuming.
|
||||||
|
-->
|
||||||
|
<network-security-config xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
<base-config
|
||||||
|
cleartextTrafficPermitted="true"
|
||||||
|
tools:ignore="InsecureBaseConfiguration" />
|
||||||
|
</network-security-config>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse rules for desired-state toggles in the offline queue.
|
||||||
|
*
|
||||||
|
* The hazard this guards against is real and silent: without collapsing, a
|
||||||
|
* queued snooze that replays AFTER the user's undo re-hides an artist they
|
||||||
|
* asked to see again, and nothing surfaces the contradiction.
|
||||||
|
*/
|
||||||
|
class SupersededToggleIdsTest {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun snoozeRow(id: Long, mbid: String, desiredSnoozed: Boolean) = CachedMutationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
SuggestionSnoozeTogglePayload.serializer(),
|
||||||
|
SuggestionSnoozeTogglePayload(mbid, "Name", desiredSnoozed),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun likeRow(id: Long, entityId: String, desired: Boolean) = CachedMutationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = MutationKind.LIKE_TOGGLE,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
LikeTogglePayload.serializer(),
|
||||||
|
LikeTogglePayload("artist", entityId, desired),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a snooze followed by its undo drops the snooze`() {
|
||||||
|
val rows = listOf(
|
||||||
|
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = false),
|
||||||
|
)
|
||||||
|
// Only the later intent (the undo) survives to be replayed.
|
||||||
|
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggles for different candidates never collapse into each other`() {
|
||||||
|
val rows = listOf(
|
||||||
|
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||||
|
snoozeRow(2, "mb-b", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only the newest of several toggles for one candidate survives`() {
|
||||||
|
val rows = listOf(
|
||||||
|
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = false),
|
||||||
|
snoozeRow(3, "mb-a", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertEquals(setOf(1L, 2L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The kind is part of the collapse key, so a snooze and a like that happen
|
||||||
|
// to share an id string must not shadow one another.
|
||||||
|
@Test
|
||||||
|
fun `a like and a snooze on the same id string do not collide`() {
|
||||||
|
val rows = listOf(
|
||||||
|
likeRow(1, "same-id", desired = true),
|
||||||
|
snoozeRow(2, "same-id", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `like toggles still collapse — the pre-existing behaviour is intact`() {
|
||||||
|
val rows = listOf(
|
||||||
|
likeRow(1, "artist-1", desired = true),
|
||||||
|
likeRow(2, "artist-1", desired = false),
|
||||||
|
)
|
||||||
|
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-toggle kinds are never collapsed, even repeated for one entity`() {
|
||||||
|
// Two appends to the same playlist are two real actions, not one
|
||||||
|
// desired state — collapsing them would lose a write.
|
||||||
|
val rows = listOf(
|
||||||
|
CachedMutationEntity(
|
||||||
|
id = 1,
|
||||||
|
kind = MutationKind.PLAYLIST_APPEND,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
PlaylistAppendPayload.serializer(),
|
||||||
|
PlaylistAppendPayload("pl-1", listOf("t1")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CachedMutationEntity(
|
||||||
|
id = 2,
|
||||||
|
kind = MutationKind.PLAYLIST_APPEND,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
PlaylistAppendPayload.serializer(),
|
||||||
|
PlaylistAppendPayload("pl-1", listOf("t2")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row whose payload won't decode gets no key at all, rather than sharing
|
||||||
|
// a "corrupt" bucket — otherwise one bad row could suppress a good one
|
||||||
|
// behind it. The dispatcher DROPs the bad row on its own.
|
||||||
|
@Test
|
||||||
|
fun `an undecodable payload does not suppress a valid later row`() {
|
||||||
|
val rows = listOf(
|
||||||
|
CachedMutationEntity(
|
||||||
|
id = 1,
|
||||||
|
kind = MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||||
|
payload = "{ not json",
|
||||||
|
),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty queue collapses nothing`() {
|
||||||
|
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,12 +50,46 @@ class ReachabilityMachineTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `two op failures plus a failed probe escalate immediately`() {
|
fun `two SPACED op failures plus a failed probe escalate immediately`() {
|
||||||
val m = machine()
|
val m = machine()
|
||||||
m.onLinkChange(up = true)
|
m.onLinkChange(up = true)
|
||||||
m.onOpFailure(nowMs = 1_000)
|
m.onOpFailure(nowMs = 1_000)
|
||||||
m.onOpFailure(nowMs = 1_500) // corroboration reached
|
// Spacing matters as of #1209: these must be far enough apart to be
|
||||||
m.onProbeFailure(nowMs = 2_000) // probe agrees → fast ServerDown
|
// separate evidence rather than one event's worth of fallout. This
|
||||||
|
// test previously used 1_500 — 500ms — which is now deliberately
|
||||||
|
// treated as a burst and does NOT corroborate.
|
||||||
|
m.onOpFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS)
|
||||||
|
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS + 500)
|
||||||
|
assertEquals(ServerHealth.ServerDown, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The #1209 mechanism: an OS network handoff fails every in-flight request
|
||||||
|
// at once. That must NOT reach ServerDown, because ServerDown makes
|
||||||
|
// OfflineGatedDataSource refuse uncached tracks outright — the app would
|
||||||
|
// decline to play music that plays fine, for a blip already over.
|
||||||
|
@Test
|
||||||
|
fun `a burst of op failures does not corroborate itself into ServerDown`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true)
|
||||||
|
m.onOpFailure(nowMs = 1_000)
|
||||||
|
m.onOpFailure(nowMs = 1_050)
|
||||||
|
m.onOpFailure(nowMs = 1_100)
|
||||||
|
m.onOpFailure(nowMs = 1_200)
|
||||||
|
m.onProbeFailure(nowMs = 1_500)
|
||||||
|
// Unstable is non-gating, so playback keeps working.
|
||||||
|
assertEquals(ServerHealth.Unstable, m.health())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a burst still escalates via the sustained backstop if it never recovers`() {
|
||||||
|
val m = machine()
|
||||||
|
m.onLinkChange(up = true)
|
||||||
|
m.onOpFailure(nowMs = 1_000)
|
||||||
|
m.onOpFailure(nowMs = 1_050)
|
||||||
|
m.onProbeFailure(nowMs = 1_500) // unstable, streak starts here
|
||||||
|
// Dropping burst duplicates must not make a REAL outage undetectable —
|
||||||
|
// the time backstop is what guarantees escalation either way.
|
||||||
|
m.onProbeFailure(nowMs = 1_500 + ESCALATE_AFTER_MS)
|
||||||
assertEquals(ServerHealth.ServerDown, m.health())
|
assertEquals(ServerHealth.ServerDown, m.health())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +98,7 @@ class ReachabilityMachineTest {
|
|||||||
val m = machine()
|
val m = machine()
|
||||||
m.onLinkChange(up = true)
|
m.onLinkChange(up = true)
|
||||||
m.onOpFailure(nowMs = 1_000)
|
m.onOpFailure(nowMs = 1_000)
|
||||||
m.onOpFailure(nowMs = 1_500)
|
m.onOpFailure(nowMs = 1_000 + CORROBORATION_MIN_SPACING_MS)
|
||||||
m.onSuccess() // arbiter says server is fine
|
m.onSuccess() // arbiter says server is fine
|
||||||
assertEquals(ServerHealth.Healthy, m.health())
|
assertEquals(ServerHealth.Healthy, m.health())
|
||||||
}
|
}
|
||||||
@@ -74,9 +108,11 @@ class ReachabilityMachineTest {
|
|||||||
val m = machine()
|
val m = machine()
|
||||||
m.onLinkChange(up = true)
|
m.onLinkChange(up = true)
|
||||||
m.onOpFailure(nowMs = 0)
|
m.onOpFailure(nowMs = 0)
|
||||||
m.onOpFailure(nowMs = 1_000)
|
// Spaced so this test exercises STALENESS, not the burst rule — with
|
||||||
|
// 1_000 it would have passed for the wrong reason after #1209.
|
||||||
|
m.onOpFailure(nowMs = CORROBORATION_MIN_SPACING_MS)
|
||||||
// both op failures are now older than the corroboration window:
|
// both op failures are now older than the corroboration window:
|
||||||
m.onProbeFailure(nowMs = 1_000 + CORROBORATION_WINDOW_MS + 1)
|
m.onProbeFailure(nowMs = CORROBORATION_MIN_SPACING_MS + CORROBORATION_WINDOW_MS + 1)
|
||||||
assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
|
assertEquals(ServerHealth.Unstable, m.health()) // not enough fresh corroboration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,4 +56,51 @@ class BuildPlaylistsRowTest {
|
|||||||
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
||||||
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun songsLike(id: String, cached: Boolean) = PlaylistRef(
|
||||||
|
id = id,
|
||||||
|
userId = "u",
|
||||||
|
name = "Songs like $id",
|
||||||
|
systemVariant = "songs_like_artist",
|
||||||
|
trackCount = 25,
|
||||||
|
fullyCached = cached,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `songs-like no longer appears in the main playlists carousel`() {
|
||||||
|
val owned = listOf(songsLike("a", cached = true), user("u1", cached = true))
|
||||||
|
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
assertTrue("a" !in reals, "songs-like mix leaked into the Playlists row: $reals")
|
||||||
|
assertTrue("u1" in reals)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `online songs-like row shows every generated mix uncapped`() {
|
||||||
|
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
|
||||||
|
val owned = (1..6).map { songsLike("a$it", cached = true) }
|
||||||
|
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
assertEquals((1..6).map { "a$it" }, reals)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `online songs-like row shows placeholders when none generated`() {
|
||||||
|
val row = buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = false)
|
||||||
|
assertTrue(row.isNotEmpty())
|
||||||
|
assertTrue(row.all { it is PlaylistRowItem.Placeholder })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `offline songs-like row shows cached mixes available-first, none hides it`() {
|
||||||
|
val owned = listOf(songsLike("partial", cached = false), songsLike("full", cached = true))
|
||||||
|
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = true)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
// Fully-cached songs-like mix (not refreshable) is available; the
|
||||||
|
// un-cached one greys and sorts after. No placeholders offline.
|
||||||
|
assertEquals(listOf("full", "partial"), reals)
|
||||||
|
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
|
||||||
|
|
||||||
|
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card's subtitle line (#2377). Wording is kept in lockstep with the web
|
||||||
|
* client's reasonText() in SuggestionFeed.svelte — these assertions are the
|
||||||
|
* record of what that wording IS, so a change on one client without the other
|
||||||
|
* shows up as a failure rather than as silent divergence between the two
|
||||||
|
* surfaces.
|
||||||
|
*/
|
||||||
|
class ArtistSuggestionReasonTest {
|
||||||
|
|
||||||
|
private val seeds = listOf(
|
||||||
|
SeedContributionRef(name = "Seed", isLiked = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun suggestion(
|
||||||
|
matched: List<String> = emptyList(),
|
||||||
|
attribution: List<SeedContributionRef> = seeds,
|
||||||
|
) = ArtistSuggestionRef(
|
||||||
|
mbid = "mb",
|
||||||
|
name = "Candidate",
|
||||||
|
attribution = attribution,
|
||||||
|
matchedTags = matched,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one matched tag reads in the singular`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in shoegaze.",
|
||||||
|
suggestion(matched = listOf("shoegaze")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two matched tags join with and`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in shoegaze and dream pop.",
|
||||||
|
suggestion(matched = listOf("shoegaze", "dream pop")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three matched tags use an Oxford comma`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in a, b, and c.",
|
||||||
|
suggestion(matched = listOf("a", "b", "c")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server caps at 3, but the client must not render a run-on line if a
|
||||||
|
// future server sends more.
|
||||||
|
@Test
|
||||||
|
fun `more than three matched tags are capped at three`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in a, b, and c.",
|
||||||
|
suggestion(matched = listOf("a", "b", "c", "d", "e")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The COMMON case: most candidates have no cached tags (#2376), so the card
|
||||||
|
// must fall back to seed attribution rather than going blank.
|
||||||
|
@Test
|
||||||
|
fun `no matched tags falls back to seed attribution`() {
|
||||||
|
assertEquals("Because you liked Seed.", suggestion().reasonText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to say at all — a candidate with neither tags nor attribution
|
||||||
|
// yields an empty line, which the tile suppresses rather than rendering as
|
||||||
|
// a blank row.
|
||||||
|
@Test
|
||||||
|
fun `neither tags nor attribution yields an empty line`() {
|
||||||
|
assertEquals("", suggestion(attribution = emptyList()).reasonText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The taste reason WINS over attribution when both exist: describing the
|
||||||
|
// music beats describing the similarity graph.
|
||||||
|
@Test
|
||||||
|
fun `a taste match supersedes seed attribution`() {
|
||||||
|
val got = suggestion(matched = listOf("shoegaze")).reasonText
|
||||||
|
assertEquals("Matches your taste in shoegaze.", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `returnsIn` phrasing for the parked-suggestions list (#2375). The clock is
|
||||||
|
* injected rather than frozen, so these assertions are stable.
|
||||||
|
*/
|
||||||
|
class SuggestionSnoozeRefTest {
|
||||||
|
|
||||||
|
private val now = 1_800_000_000_000L // fixed epoch ms; any value works
|
||||||
|
|
||||||
|
private fun snoozeIn(days: Double) = SuggestionSnoozeRef(
|
||||||
|
mbid = "mb",
|
||||||
|
name = "Parked",
|
||||||
|
snoozedUntil = Instant
|
||||||
|
.fromEpochMilliseconds(now + (days * 86_400_000L).toLong())
|
||||||
|
.toString(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the default 90-day snooze reads as about 3 months`() {
|
||||||
|
assertEquals("in about 3 months", snoozeIn(90.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a month reads in the singular`() {
|
||||||
|
assertEquals("in about a month", snoozeIn(30.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `under the month threshold it counts days`() {
|
||||||
|
assertEquals("in 14 days", snoozeIn(14.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pins the days→months boundary. The singular branch was originally dead
|
||||||
|
// code because the threshold (45) sat above the divisor (30), so no day
|
||||||
|
// count could ever round to one month without being caught by the days
|
||||||
|
// branch first. Asserting both sides of the seam keeps that from
|
||||||
|
// regressing silently.
|
||||||
|
@Test
|
||||||
|
fun `the days-to-months boundary is exactly at 30 days`() {
|
||||||
|
assertEquals("in 29 days", snoozeIn(29.0).returnsIn(now))
|
||||||
|
assertEquals("in about a month", snoozeIn(30.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `well past a month still reads in the singular rather than jumping to two`() {
|
||||||
|
assertEquals("in about a month", snoozeIn(40.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tomorrow is named, not rendered as 1 days`() {
|
||||||
|
assertEquals("tomorrow", snoozeIn(1.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `later today rounds down to today rather than going negative`() {
|
||||||
|
assertEquals("today", snoozeIn(0.1).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server only ever returns unexpired rows, so a past timestamp means
|
||||||
|
// our clock and the server's disagree. The row is on screen either way, so
|
||||||
|
// say something plausible rather than leaving the line blank.
|
||||||
|
@Test
|
||||||
|
fun `an already-past expiry degrades to shortly`() {
|
||||||
|
assertEquals("shortly", snoozeIn(-5.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unparseable timestamp degrades to shortly`() {
|
||||||
|
val row = SuggestionSnoozeRef(mbid = "mb", name = "Parked", snoozedUntil = "not-a-date")
|
||||||
|
assertEquals("shortly", row.returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty timestamp degrades to shortly`() {
|
||||||
|
val row = SuggestionSnoozeRef(mbid = "mb", name = "Parked", snoozedUntil = "")
|
||||||
|
assertEquals("shortly", row.returnsIn(now))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package com.fabledsword.minstrel.shared
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
private const val WORK_MS = 1_000L
|
||||||
|
private const val CHURN_ROUNDS = 5
|
||||||
|
private const val ART_IN_FLIGHT = 3
|
||||||
|
private const val SUCCEED_ON_ATTEMPT = 3
|
||||||
|
private const val QUIET_WINDOWS_TO_OUTLAST = 3
|
||||||
|
|
||||||
|
// Time to let a session finish once the screen has stopped changing: the
|
||||||
|
// retry backoffs, the quiet window and the minimum hold all fit inside it,
|
||||||
|
// while staying well under maxHoldMs. That gap matters — if a drain ran
|
||||||
|
// past the ceiling, "the veil lowered" would no longer distinguish
|
||||||
|
// "it settled" from "it gave up", which is the whole point of these tests.
|
||||||
|
private const val DRAIN_MS = 3_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The veil's job is to go up only when content actually changes, and then to
|
||||||
|
* stay up until the screen has stopped moving. Each test pins one of the ways
|
||||||
|
* the original fixed-delay implementation got that wrong (issue #2327).
|
||||||
|
*
|
||||||
|
* The controller is built on `backgroundScope` throughout: its consumer
|
||||||
|
* loop runs forever, so hanging it off the test's own scope would stop
|
||||||
|
* `runTest` from ever completing.
|
||||||
|
*
|
||||||
|
* Consequence, and the reason every wait below is an explicit
|
||||||
|
* `advanceTimeBy`: **`advanceUntilIdle()` is useless here.** It advances
|
||||||
|
* only while *foreground* work remains, and everything this controller
|
||||||
|
* does lives in `backgroundScope` — so it returns having run nothing, and
|
||||||
|
* assertions land on a session that never started (CI run 3163 failed all
|
||||||
|
* seven of these with "expected 3, actual 0" and friends). Drive the clock
|
||||||
|
* deliberately instead; don't "simplify" these back to advanceUntilIdle.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class UpdateVeilControllerTest {
|
||||||
|
|
||||||
|
private val timings = VeilTimings(
|
||||||
|
minHoldMs = 900,
|
||||||
|
quietMs = 700,
|
||||||
|
maxHoldMs = 12_000,
|
||||||
|
attempts = 3,
|
||||||
|
retryBackoffMs = 600,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Drives the settle signal by hand: content key, presence, art count. */
|
||||||
|
private class FakeScreen(hasContent: Boolean = true) {
|
||||||
|
val state = MutableStateFlow(Triple(0, hasContent, 0))
|
||||||
|
|
||||||
|
val signal = state.map { (key, hasContent, art) ->
|
||||||
|
VeilSettleState(contentKey = key, hasContent = hasContent, quiescent = art == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content visibly changed — what the veil exists to cover. */
|
||||||
|
fun churn() {
|
||||||
|
state.value = state.value.copy(first = state.value.first + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun artLoading(count: Int) {
|
||||||
|
state.value = state.value.copy(third = count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contentAppears() {
|
||||||
|
state.value = state.value.copy(second = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.controllerOn(
|
||||||
|
screen: FakeScreen,
|
||||||
|
shouldVeil: suspend () -> Boolean = { true },
|
||||||
|
onSessionEnd: (VeilSessionResult) -> Unit = {},
|
||||||
|
work: suspend () -> Boolean,
|
||||||
|
) = UpdateVeilController(
|
||||||
|
scope = backgroundScope,
|
||||||
|
settleSignal = screen.signal,
|
||||||
|
shouldVeil = shouldVeil,
|
||||||
|
timings = timings,
|
||||||
|
onSessionEnd = onSessionEnd,
|
||||||
|
work = work,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Records every visibility transition, so an extra raise can't hide. */
|
||||||
|
private fun TestScope.recordVisibility(controller: UpdateVeilController): List<Boolean> {
|
||||||
|
val seen = mutableListOf<Boolean>()
|
||||||
|
backgroundScope.launch { controller.visible.collect { seen.add(it) } }
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `veil outlasts content that keeps churning after the pull returns`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val controller = controllerOn(screen) { true }
|
||||||
|
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
// The pull's write lands: content changed, so the veil goes up.
|
||||||
|
screen.churn()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(controller.visible.value, "veil is up while the screen is still moving")
|
||||||
|
|
||||||
|
// Tiles hydrating one after another, each inside the quiet window.
|
||||||
|
// The old implementation had already wiped off after a flat 500ms.
|
||||||
|
repeat(CHURN_ROUNDS) {
|
||||||
|
advanceTimeBy(timings.quietMs / 2)
|
||||||
|
screen.churn()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(controller.visible.value, "veil must hold across staged churn")
|
||||||
|
}
|
||||||
|
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertFalse(controller.visible.value, "veil lowers once the screen goes quiet")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `veil waits for artwork to finish loading`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val controller = controllerOn(screen) { true }
|
||||||
|
|
||||||
|
screen.artLoading(ART_IN_FLIGHT)
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
screen.churn()
|
||||||
|
runCurrent()
|
||||||
|
// Well past the quiet window and the floor — but art is still in
|
||||||
|
// flight, so lowering now would show the covers popping in.
|
||||||
|
advanceTimeBy(timings.minHoldMs + timings.quietMs * QUIET_WINDOWS_TO_OUTLAST)
|
||||||
|
assertTrue(controller.visible.value, "veil must wait on in-flight art")
|
||||||
|
|
||||||
|
screen.artLoading(0)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertFalse(controller.visible.value, "veil lowers once art has landed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retries quietly, then veils the churn the successful attempt produces`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
var attempts = 0
|
||||||
|
val controller = controllerOn(screen) {
|
||||||
|
attempts++
|
||||||
|
val succeeded = attempts >= SUCCEED_ON_ATTEMPT // fail twice
|
||||||
|
// Only a pull that worked writes anything.
|
||||||
|
if (succeeded) screen.churn()
|
||||||
|
succeeded
|
||||||
|
}
|
||||||
|
val seen = recordVisibility(controller)
|
||||||
|
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
// A failed pull changes nothing, so there is nothing to hide yet —
|
||||||
|
// the retries happen with no veil at all.
|
||||||
|
assertFalse(controller.visible.value, "no veil over a pull that changed nothing")
|
||||||
|
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(SUCCEED_ON_ATTEMPT, attempts, "retries until the pull succeeds")
|
||||||
|
assertEquals(
|
||||||
|
listOf(false, true, false),
|
||||||
|
seen,
|
||||||
|
"the veil went up once, over the churn the retry finally produced",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `giving up is silent, reports FAILED, and does not block later requests`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
var attempts = 0
|
||||||
|
var succeed = false
|
||||||
|
val controller = controllerOn(screen, onSessionEnd = { results += it }) {
|
||||||
|
attempts++
|
||||||
|
succeed
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(timings.attempts, attempts, "exhausts its attempts")
|
||||||
|
assertFalse(controller.visible.value, "no veil — a failed pull changed nothing")
|
||||||
|
assertEquals(VeilOutcome.FAILED, results.single().outcome)
|
||||||
|
assertTrue(results.single().userInitiated, "the user asked, so they're owed an answer")
|
||||||
|
|
||||||
|
// Giving up must not latch anything off — the reconnect-driven
|
||||||
|
// recovery still gets to try again later.
|
||||||
|
succeed = true
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(timings.attempts + 1, attempts, "a later request still runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overlapping requests extend one veil instead of racing it`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
var started = 0
|
||||||
|
val controller = controllerOn(screen) {
|
||||||
|
started++
|
||||||
|
delay(WORK_MS)
|
||||||
|
screen.churn()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
val seen = recordVisibility(controller)
|
||||||
|
|
||||||
|
// Reconnect and the rebuild event arriving together is what made the
|
||||||
|
// old Boolean flag clear mid-update: whichever pull finished first
|
||||||
|
// wiped the veil off while the other was still running.
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(WORK_MS + 1)
|
||||||
|
assertTrue(controller.visible.value, "second trigger extends the same veil")
|
||||||
|
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(2, started, "the mid-session trigger still did its pull")
|
||||||
|
assertEquals(listOf(false, true, false), seen, "one veil session, not two")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a never-settling screen still releases the veil at the ceiling`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val controller = controllerOn(screen) {
|
||||||
|
screen.churn()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.artLoading(1) // an image that never completes
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(timings.maxHoldMs + 1)
|
||||||
|
assertFalse(controller.visible.value, "the hard ceiling must never strand the user")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unchanged refresh never raises the veil and reports UNCHANGED`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
// Succeeds without writing anything — the common case on a launch
|
||||||
|
// over a warm cache, where the server returns what's already cached.
|
||||||
|
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
|
||||||
|
val seen = recordVisibility(controller)
|
||||||
|
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertEquals(listOf(false), seen, "a veil over an unchanged screen would hide nothing")
|
||||||
|
assertEquals(VeilOutcome.UNCHANGED, results.single().outcome)
|
||||||
|
assertTrue(results.single().userInitiated, "so the caller can say 'already up to date'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cached content painting is not mistaken for a change`() = runTest {
|
||||||
|
// Warm cache that hasn't painted yet: the rows arrive a moment after
|
||||||
|
// the session starts. Treating that first paint as churn would veil
|
||||||
|
// every single launch.
|
||||||
|
val screen = FakeScreen(hasContent = false)
|
||||||
|
val controller = controllerOn(screen) { true }
|
||||||
|
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
screen.contentAppears()
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertFalse(controller.visible.value, "first paint is not churn")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a background trigger coalescing with the user's does not swallow their answer`() =
|
||||||
|
runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
|
||||||
|
|
||||||
|
// Conflation drops the older token, so the "a user asked" bit
|
||||||
|
// cannot ride on it — it's tracked separately for exactly this.
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
results.first().userInitiated,
|
||||||
|
"the user's request must not be conflated away",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cold load runs unveiled and is never reported as already up to date`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
var ran = false
|
||||||
|
val controller = controllerOn(
|
||||||
|
screen,
|
||||||
|
shouldVeil = { false }, // empty cache: the skeleton owns this
|
||||||
|
onSessionEnd = { results += it },
|
||||||
|
) {
|
||||||
|
ran = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertTrue(ran, "the refresh still happens")
|
||||||
|
assertFalse(controller.visible.value, "but no veil over a skeleton")
|
||||||
|
// It went from nothing to something — that IS a change.
|
||||||
|
assertEquals(VeilOutcome.CHANGED, results.single().outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,17 @@ Minstrel's four workflows consume two CI images:
|
|||||||
|
|
||||||
```
|
```
|
||||||
git.fabledsword.com/bvandeusen/ci-go:1.26
|
git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
git.fabledsword.com/bvandeusen/ci-android:36
|
||||||
```
|
```
|
||||||
|
|
||||||
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`.gitea/workflows/release.yml`).
|
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job).
|
||||||
- `ci-flutter:3.44` — Flutter client tests + debug/release APK builds (`.gitea/workflows/flutter.yml`).
|
- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job).
|
||||||
|
|
||||||
|
**`ci-flutter` is no longer consumed.** The M8 rewrite replaced the Flutter
|
||||||
|
client with the native Android app and `flutter.yml` was removed; `ci-android`
|
||||||
|
took its place. `flutter_client/` is still in the tree but nothing builds it.
|
||||||
|
CI-Runner still publishes `ci-flutter` and will retire it once that directory
|
||||||
|
goes — so if the Flutter client is ever revived, say so there first.
|
||||||
|
|
||||||
## Image deps used
|
## Image deps used
|
||||||
|
|
||||||
@@ -25,13 +31,20 @@ git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
|||||||
- **docker buildx** — release container build + push in `release.yml`.
|
- **docker buildx** — release container build + push in `release.yml`.
|
||||||
- **curl** — release-asset polling / upload in `release.yml`.
|
- **curl** — release-asset polling / upload in `release.yml`.
|
||||||
|
|
||||||
### From `ci-flutter:3.44`
|
### From `ci-android:36`
|
||||||
- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release).
|
- **JDK 25** — Gradle launcher + Android build. Requires Gradle 9.1.0+ in
|
||||||
- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen).
|
`android/gradle/wrapper`; older Gradle rejects JDK 25 with an opaque `"25.0.3"`
|
||||||
- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing.
|
error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED`
|
||||||
- **Java 25** — Gradle / Android build.
|
to silence Gradle's launcher-JVM restricted-method warning.
|
||||||
|
- **Android SDK + cmdline-tools + build-tools 36.0.0** — APK assembly + signing.
|
||||||
|
No NDK: the native client has no C/C++ sources (this is why it isn't on
|
||||||
|
`ci-flutter`).
|
||||||
|
- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in
|
||||||
|
`android.yml`. Image pins track `android/gradle/libs.versions.toml` so local
|
||||||
|
and CI checks agree.
|
||||||
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
||||||
- **base64 + curl** — keystore decode + release-asset upload in the tag-build path.
|
- **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s
|
||||||
|
`android-release` job.
|
||||||
|
|
||||||
## Per-job tool installs
|
## Per-job tool installs
|
||||||
|
|
||||||
@@ -39,9 +52,69 @@ None.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions.
|
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way.
|
||||||
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
||||||
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
||||||
- **In-app update channel polling.** `release.yml` polls Gitea's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure).
|
- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
|
||||||
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
||||||
|
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
|
||||||
|
```yaml
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||||
|
```
|
||||||
|
Upstream's `@v4+` cannot work against this instance and no server-side change
|
||||||
|
will help: `isGhes()` rejects any hostname that isn't `github.com` /
|
||||||
|
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
|
||||||
|
server is never asked what it supports. `@v3` is worse — it reports success,
|
||||||
|
and Gitea then serves artifacts back only through the v4 API
|
||||||
|
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
|
||||||
|
to every retrieval path. A green job producing nothing retrievable; that is how
|
||||||
|
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
|
||||||
|
|
||||||
|
Both are pull mirrors of the Forgejo project's forks
|
||||||
|
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
|
||||||
|
disabling that check), mirrored so CI depends on commits we hold and pinned by
|
||||||
|
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
|
||||||
|
otherwise silently change what runs.
|
||||||
|
|
||||||
|
**Match the pins on `@actions/artifact`, not on the actions' own version
|
||||||
|
numbers.** The two actions release on unrelated cadences, so equal version
|
||||||
|
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
|
||||||
|
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
|
||||||
|
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
|
||||||
|
matters because `release.yml` is a producer/consumer pair — `android-release`
|
||||||
|
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
|
||||||
|
across it yields an empty listing rather than an error, exactly the silent
|
||||||
|
failure this entry exists to prevent.
|
||||||
|
|
||||||
|
| tag | `@actions/artifact` | runtime |
|
||||||
|
|---|---|---|
|
||||||
|
| upload v4 | ^2.1.1 | node20 |
|
||||||
|
| **upload v5** ← pinned | **^4.0.0** | node20 |
|
||||||
|
| download v4 | ^2.1.1 | node20 |
|
||||||
|
| download v5 | ^2.3.2 | node20 |
|
||||||
|
| **download v6** ← pinned | **^4.0.0** | node20 |
|
||||||
|
| download v7 | ^5.0.0 | **node24** |
|
||||||
|
|
||||||
|
The only true protocol break in this history was **v3 → v4** (upstream:
|
||||||
|
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
|
||||||
|
below are not supported"); v4-and-up are one family. Later majors are mostly
|
||||||
|
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
|
||||||
|
at 500 artifacts; download v5 made by-ID extraction match by-name.
|
||||||
|
|
||||||
|
**Do not jump the download pin to v7.** That major is a runner requirement, not
|
||||||
|
a feature change: it moves to `runs.using: node24` and upstream states it
|
||||||
|
"requires a minimum Actions Runner version of 2.327.1 … if you are using
|
||||||
|
self-hosted runners, ensure they are updated before upgrading." act_runner is
|
||||||
|
not GitHub's runner and makes no such version claim, so node24 is unverified
|
||||||
|
here. Everything currently pinned is node20.
|
||||||
|
|
||||||
|
Upload steps set `if-no-files-found: error` rather than the default `warn`, so
|
||||||
|
an upload that matches nothing fails its own job instead of failing the
|
||||||
|
consumer later.
|
||||||
|
|
||||||
|
Retrieval: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
|
||||||
|
for the id (global run id, not the repo-scoped run number), then
|
||||||
|
`…/actions/artifacts/{id}/zip`. The workstation has no `unzip` — use
|
||||||
|
`python3 -m zipfile -e`.
|
||||||
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
|
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
@@ -25,12 +26,14 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -146,6 +149,16 @@ func run() error {
|
|||||||
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
||||||
coverEnricher.DataDir = cfg.Storage.DataDir
|
coverEnricher.DataDir = cfg.Storage.DataDir
|
||||||
|
|
||||||
|
// Recommendation tuning lab (#1250): seeds shipped defaults on first
|
||||||
|
// boot and pushes the daily_mix weights + taste config into package
|
||||||
|
// playlists — must precede the scheduler so the first builds score
|
||||||
|
// with the operator's tuned values, not the pre-push literals.
|
||||||
|
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("recommendation settings service init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
||||||
// Boot-time scan and manual-trigger scans share this path; results land
|
// Boot-time scan and manual-trigger scans share this path; results land
|
||||||
// in scan_runs for the admin overview.
|
// in scan_runs for the admin overview.
|
||||||
@@ -194,6 +207,31 @@ func run() error {
|
|||||||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||||||
go similarityWorker.Run(ctx)
|
go similarityWorker.Run(ctx)
|
||||||
|
|
||||||
|
// Start the household co-play worker (#1533). Recomputes artist–artist
|
||||||
|
// co-occurrence edges (source='user_cooccurrence') from play_events every
|
||||||
|
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
|
||||||
|
// SQL, no external calls; empty on single-user servers.
|
||||||
|
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
|
||||||
|
|
||||||
|
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
||||||
|
// tag providers with tag_provider_settings, bumps the sources version if
|
||||||
|
// the provider set changed (re-opening settled rows), then drains tracks
|
||||||
|
// needing folksonomy tags on a periodic tick. Standalone (not in the file
|
||||||
|
// scan chain) because tag lookups need only DB fields — MBID / artist /
|
||||||
|
// title — that a scan has already imported.
|
||||||
|
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("tag settings service init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
|
||||||
|
logger.Warn("tags: provider-hash boot check failed", "err", berr)
|
||||||
|
} else if bumped {
|
||||||
|
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
|
||||||
|
}
|
||||||
|
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
|
||||||
|
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
|
||||||
|
|
||||||
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
||||||
// that have no writer-side close path or retention policy:
|
// that have no writer-side close path or retention policy:
|
||||||
// orphan play_events, stale play_sessions, expired
|
// orphan play_events, stale play_sessions, expired
|
||||||
@@ -294,6 +332,8 @@ func run() error {
|
|||||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||||||
srv.Bus = bus
|
srv.Bus = bus
|
||||||
srv.PlaylistScheduler = playlistScheduler
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
|
srv.RecSettings = recSettings
|
||||||
|
srv.TagSettings = tagSettings
|
||||||
srv.StreamSecret = cfg.StreamSecret
|
srv.StreamSecret = cfg.StreamSecret
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type networkSettingsResp struct {
|
||||||
|
TrustedProxyHops int `json:"trusted_proxy_hops"`
|
||||||
|
MaxHops int `json:"max_hops"`
|
||||||
|
// DetectedClientIP is what the CURRENT setting resolves this very request
|
||||||
|
// to. It's the difference between a number the operator has to reason
|
||||||
|
// about and one they can verify: set the value, reload, and check the
|
||||||
|
// address matches the machine you're sitting at.
|
||||||
|
DetectedClientIP string `json:"detected_client_ip"`
|
||||||
|
// ForwardedChain is the raw X-Forwarded-For as received, so an operator
|
||||||
|
// whose detected address looks wrong can see how many hops actually
|
||||||
|
// arrived and count them rather than guess.
|
||||||
|
ForwardedChain string `json:"forwarded_chain"`
|
||||||
|
RemoteAddr string `json:"remote_addr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateNetworkSettingsReq struct {
|
||||||
|
TrustedProxyHops int `json:"trusted_proxy_hops"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleGetNetworkSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) handleUpdateNetworkSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req updateNetworkSettingsReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", "malformed JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.netSettings.SetHops(r.Context(), req.TrustedProxyHops); err != nil {
|
||||||
|
if errors.Is(err, netsettings.ErrHopsOutOfRange) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_hops", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErrWithLog(w, h.logger, "admin network: update failed", apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Echo the payload recomputed under the NEW value, so the card can show
|
||||||
|
// immediately what the change did to this request's own address rather
|
||||||
|
// than making the operator reload to find out.
|
||||||
|
writeJSON(w, http.StatusOK, h.networkSettingsPayload(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) networkSettingsPayload(r *http.Request) networkSettingsResp {
|
||||||
|
hops := h.netSettings.Hops()
|
||||||
|
return networkSettingsResp{
|
||||||
|
TrustedProxyHops: hops,
|
||||||
|
MaxHops: netsettings.MaxTrustedProxyHops,
|
||||||
|
DetectedClientIP: auth.ClientIP(r, hops),
|
||||||
|
ForwardedChain: r.Header.Get("X-Forwarded-For"),
|
||||||
|
RemoteAddr: r.RemoteAddr,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Admin recommendation-trends endpoint (#1251): weekly per-surface
|
||||||
|
// outcome series with tuning-audit markers — the verify half of the
|
||||||
|
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
|
||||||
|
// all users because the knobs are global; rows carry rates only.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
trendsDefaultWeeks = 12
|
||||||
|
trendsMaxWeeks = 52
|
||||||
|
// trendsAuditFetchCap bounds the audit fetch; markers older than
|
||||||
|
// the window are dropped in Go. Far above any real knob-turn count
|
||||||
|
// inside a year.
|
||||||
|
trendsAuditFetchCap = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// trendPoint is one week of one surface family's outcomes.
|
||||||
|
type trendPoint struct {
|
||||||
|
WeekStart string `json:"week_start"` // ISO date (Monday)
|
||||||
|
Plays int64 `json:"plays"`
|
||||||
|
Skips int64 `json:"skips"`
|
||||||
|
SkipRate float64 `json:"skip_rate"`
|
||||||
|
AvgCompletion float64 `json:"avg_completion"`
|
||||||
|
// TasteHitRate is the share of plays whose track's artist carries a
|
||||||
|
// positive weight in the player's current taste profile — a drifted
|
||||||
|
// but retroactive read on whether the surface feeds taste-fitting
|
||||||
|
// tracks.
|
||||||
|
TasteHitRate float64 `json:"taste_hit_rate"`
|
||||||
|
// completionN carries the completion sample count through same-week
|
||||||
|
// merges so avg_completion stays sample-weighted; not serialized.
|
||||||
|
completionN int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// trendSeries is one surface family's weekly series.
|
||||||
|
type trendSeries struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
|
||||||
|
Plays int64 `json:"plays"` // window total, for sorting/volume-gating
|
||||||
|
Points []trendPoint `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// trendMarker is one tuning-audit event annotated on the timeline.
|
||||||
|
type trendMarker struct {
|
||||||
|
ChangedAt string `json:"changed_at"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Changes json.RawMessage `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type trendsResp struct {
|
||||||
|
Weeks int `json:"weeks"`
|
||||||
|
Series []trendSeries `json:"series"`
|
||||||
|
Markers []trendMarker `json:"markers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationTrends implements
|
||||||
|
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
|
||||||
|
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
|
||||||
|
weeks := trendsDefaultWeeks
|
||||||
|
if v := r.URL.Query().Get("weeks"); v != "" {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > trendsMaxWeeks {
|
||||||
|
n = trendsMaxWeeks
|
||||||
|
}
|
||||||
|
weeks = n
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: recommendation trends", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: recommendation trends audit", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTrendsResponse folds weekly rows into per-family series and
|
||||||
|
// windows the audit markers. Split from the handler for pure-unit
|
||||||
|
// testability.
|
||||||
|
func buildTrendsResponse(
|
||||||
|
weeks int, now time.Time,
|
||||||
|
rows []dbq.RecommendationWeeklyTrendsRow,
|
||||||
|
audits []dbq.RecommendationTuningAudit,
|
||||||
|
) trendsResp {
|
||||||
|
type accum struct {
|
||||||
|
fam recFamily
|
||||||
|
plays int64
|
||||||
|
points []trendPoint
|
||||||
|
}
|
||||||
|
families := map[string]*accum{}
|
||||||
|
for _, row := range rows {
|
||||||
|
fam := recFamily{key: "manual", label: "Manual library plays"}
|
||||||
|
if row.Source != nil && *row.Source != "" {
|
||||||
|
fam = bucketRecSource(*row.Source)
|
||||||
|
}
|
||||||
|
acc, ok := families[fam.key]
|
||||||
|
if !ok {
|
||||||
|
acc = &accum{fam: fam}
|
||||||
|
families[fam.key] = acc
|
||||||
|
}
|
||||||
|
acc.plays += row.Plays
|
||||||
|
p := trendPoint{
|
||||||
|
WeekStart: row.WeekStart.Time.Format("2006-01-02"),
|
||||||
|
Plays: row.Plays,
|
||||||
|
Skips: row.Skips,
|
||||||
|
AvgCompletion: row.AvgCompletion,
|
||||||
|
completionN: row.CompletionN,
|
||||||
|
}
|
||||||
|
if row.Plays > 0 {
|
||||||
|
p.SkipRate = float64(row.Skips) / float64(row.Plays)
|
||||||
|
p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
|
||||||
|
}
|
||||||
|
// Same family can arrive as several raw sources (radio:<uuid>);
|
||||||
|
// merge same-week points play-weighted.
|
||||||
|
if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
|
||||||
|
acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
|
||||||
|
} else {
|
||||||
|
acc.points = append(acc.points, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
|
||||||
|
for _, acc := range families {
|
||||||
|
resp.Series = append(resp.Series, trendSeries{
|
||||||
|
Key: acc.fam.key,
|
||||||
|
Label: acc.fam.label,
|
||||||
|
Intent: acc.fam.intent,
|
||||||
|
Plays: acc.plays,
|
||||||
|
Points: acc.points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(resp.Series, func(i, j int) bool {
|
||||||
|
if resp.Series[i].Plays != resp.Series[j].Plays {
|
||||||
|
return resp.Series[i].Plays > resp.Series[j].Plays
|
||||||
|
}
|
||||||
|
return resp.Series[i].Key < resp.Series[j].Key
|
||||||
|
})
|
||||||
|
|
||||||
|
cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
|
||||||
|
for _, a := range audits {
|
||||||
|
if a.ChangedAt.Time.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Markers = append(resp.Markers, trendMarker{
|
||||||
|
ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
|
||||||
|
Scope: a.Scope,
|
||||||
|
Action: a.Action,
|
||||||
|
Changes: json.RawMessage(a.Changes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// ListTuningAudit returns newest-first; the timeline reads better
|
||||||
|
// oldest-first.
|
||||||
|
sort.Slice(resp.Markers, func(i, j int) bool {
|
||||||
|
return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
|
||||||
|
})
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeTrendPoints combines two same-week points of one family:
|
||||||
|
// counts add, skip/taste rates re-derive from the merged counts, and
|
||||||
|
// avg_completion is weighted by each side's completion sample count.
|
||||||
|
func mergeTrendPoints(a, b trendPoint) trendPoint {
|
||||||
|
out := trendPoint{
|
||||||
|
WeekStart: a.WeekStart,
|
||||||
|
Plays: a.Plays + b.Plays,
|
||||||
|
Skips: a.Skips + b.Skips,
|
||||||
|
completionN: a.completionN + b.completionN,
|
||||||
|
}
|
||||||
|
if out.Plays > 0 {
|
||||||
|
out.SkipRate = float64(out.Skips) / float64(out.Plays)
|
||||||
|
out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
|
||||||
|
float64(out.Plays)
|
||||||
|
}
|
||||||
|
if out.completionN > 0 {
|
||||||
|
out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
|
||||||
|
float64(out.completionN)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func date(s string) pgtype.Date {
|
||||||
|
t, _ := time.Parse("2006-01-02", s)
|
||||||
|
return pgtype.Date{Time: t, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTrendsResponse_SeriesMergingAndMarkers(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
rows := []dbq.RecommendationWeeklyTrendsRow{
|
||||||
|
// Two radio session sources in the same week collapse into one
|
||||||
|
// family point; completion weighting by sample count.
|
||||||
|
{WeekStart: date("2026-06-22"), Source: src("radio:aaaa"),
|
||||||
|
Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 0.6, TasteHits: 3},
|
||||||
|
{WeekStart: date("2026-06-22"), Source: src("radio:bbbb"),
|
||||||
|
Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2, TasteHits: 0},
|
||||||
|
{WeekStart: date("2026-06-29"), Source: src("radio:aaaa"),
|
||||||
|
Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9, TasteHits: 1},
|
||||||
|
// NULL source = manual baseline family.
|
||||||
|
{WeekStart: date("2026-06-29"), Source: nil,
|
||||||
|
Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.8, TasteHits: 4},
|
||||||
|
}
|
||||||
|
audits := []dbq.RecommendationTuningAudit{
|
||||||
|
{ID: 2, ChangedAt: pgtype.Timestamptz{Time: now.Add(-24 * time.Hour), Valid: true},
|
||||||
|
Scope: "radio", Action: "update", Changes: []byte(`[{"field":"taste_weight","old":1,"new":2}]`)},
|
||||||
|
// Older than the window → dropped.
|
||||||
|
{ID: 1, ChangedAt: pgtype.Timestamptz{Time: now.Add(-100 * 7 * 24 * time.Hour), Valid: true},
|
||||||
|
Scope: "taste", Action: "reset", Changes: []byte(`[]`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildTrendsResponse(12, now, rows, audits)
|
||||||
|
|
||||||
|
if len(resp.Series) != 2 {
|
||||||
|
t.Fatalf("series = %d, want 2 (radio + manual)", len(resp.Series))
|
||||||
|
}
|
||||||
|
radio := resp.Series[0] // 6 plays > manual's 5 → sorted first
|
||||||
|
if radio.Key != "radio" || radio.Plays != 6 {
|
||||||
|
t.Fatalf("series[0] = %s/%d, want radio/6", radio.Key, radio.Plays)
|
||||||
|
}
|
||||||
|
if len(radio.Points) != 2 {
|
||||||
|
t.Fatalf("radio points = %d, want 2 weeks", len(radio.Points))
|
||||||
|
}
|
||||||
|
wk1 := radio.Points[0]
|
||||||
|
if wk1.WeekStart != "2026-06-22" || wk1.Plays != 4 || wk1.Skips != 2 {
|
||||||
|
t.Errorf("week1 = %+v, want 2026-06-22 with 4 plays / 2 skips", wk1)
|
||||||
|
}
|
||||||
|
if wk1.SkipRate != 0.5 {
|
||||||
|
t.Errorf("week1 skip_rate = %v, want 0.5", wk1.SkipRate)
|
||||||
|
}
|
||||||
|
// Completion weighted by sample count: (0.6*3 + 0.2*1) / 4 = 0.5.
|
||||||
|
if wk1.AvgCompletion < 0.49 || wk1.AvgCompletion > 0.51 {
|
||||||
|
t.Errorf("week1 avg_completion = %v, want 0.5", wk1.AvgCompletion)
|
||||||
|
}
|
||||||
|
// Taste hits: 3 of 4 plays.
|
||||||
|
if wk1.TasteHitRate != 0.75 {
|
||||||
|
t.Errorf("week1 taste_hit_rate = %v, want 0.75", wk1.TasteHitRate)
|
||||||
|
}
|
||||||
|
if manual := resp.Series[1]; manual.Key != "manual" || manual.Intent != "" {
|
||||||
|
t.Errorf("series[1] = %+v, want the manual baseline family", manual)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Markers) != 1 {
|
||||||
|
t.Fatalf("markers = %d, want 1 (out-of-window marker dropped)", len(resp.Markers))
|
||||||
|
}
|
||||||
|
if resp.Markers[0].Scope != "radio" || resp.Markers[0].Action != "update" {
|
||||||
|
t.Errorf("marker = %+v, want radio/update", resp.Markers[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTrendsRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/admin/recommendation-trends", h.handleGetRecommendationTrends)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTrends_EndToEnd(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
user := seedUser(t, pool, "trends", "pw", true)
|
||||||
|
artist := seedArtist(t, pool, "TrendArtist")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "TrendAlbum", 2020)
|
||||||
|
tk := seedTrack(t, pool, album.ID, artist.ID, "TrendTrack", 1, 200000)
|
||||||
|
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||||
|
|
||||||
|
// Positive taste weight for the artist → plays count as taste hits.
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 1.5)`,
|
||||||
|
user.ID, artist.ID); err != nil {
|
||||||
|
t.Fatalf("seed taste profile: %v", err)
|
||||||
|
}
|
||||||
|
radio := "radio"
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radio, nil, 0.9, false)
|
||||||
|
// A knob turn to mark the timeline.
|
||||||
|
if err := h.recSettings.UpdateProfile(context.Background(), "radio",
|
||||||
|
map[string]float64{"taste_weight": 2}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTrendsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp trendsResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Weeks != trendsDefaultWeeks {
|
||||||
|
t.Errorf("weeks = %d, want %d", resp.Weeks, trendsDefaultWeeks)
|
||||||
|
}
|
||||||
|
var radioSeries *trendSeries
|
||||||
|
for i := range resp.Series {
|
||||||
|
if resp.Series[i].Key == "radio" {
|
||||||
|
radioSeries = &resp.Series[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if radioSeries == nil || len(radioSeries.Points) != 1 {
|
||||||
|
t.Fatalf("radio series = %+v, want one point", radioSeries)
|
||||||
|
}
|
||||||
|
if radioSeries.Points[0].TasteHitRate != 1.0 {
|
||||||
|
t.Errorf("taste_hit_rate = %v, want 1.0 (positive-weight artist)",
|
||||||
|
radioSeries.Points[0].TasteHitRate)
|
||||||
|
}
|
||||||
|
if len(resp.Markers) != 1 || resp.Markers[0].Scope != "radio" {
|
||||||
|
t.Errorf("markers = %+v, want the radio knob turn", resp.Markers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTrends_InvalidWeeks(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends?weeks=zero", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTrendsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Admin recommendation-tuning endpoints (#1250): the defaults-
|
||||||
|
// discovery lab. GET returns current values + shipped defaults for
|
||||||
|
// every scope; PATCH applies a partial update to one scope; reset
|
||||||
|
// restores a scope to shipped defaults. Every change writes an audit
|
||||||
|
// row (consumed by the metrics trend view, #1251).
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// weightsResp is one weight profile on the wire, keyed by the same
|
||||||
|
// snake_case field names the PATCH body accepts.
|
||||||
|
type weightsResp struct {
|
||||||
|
BaseWeight float64 `json:"base_weight"`
|
||||||
|
LikeBoost float64 `json:"like_boost"`
|
||||||
|
RecencyWeight float64 `json:"recency_weight"`
|
||||||
|
SkipPenalty float64 `json:"skip_penalty"`
|
||||||
|
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||||
|
ContextWeight float64 `json:"context_weight"`
|
||||||
|
SimilarityWeight float64 `json:"similarity_weight"`
|
||||||
|
TasteWeight float64 `json:"taste_weight"`
|
||||||
|
ContextTimeWeight float64 `json:"context_time_weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
||||||
|
return weightsResp{
|
||||||
|
BaseWeight: w.BaseWeight,
|
||||||
|
LikeBoost: w.LikeBoost,
|
||||||
|
RecencyWeight: w.RecencyWeight,
|
||||||
|
SkipPenalty: w.SkipPenalty,
|
||||||
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
|
ContextWeight: w.ContextWeight,
|
||||||
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
|
TasteWeight: w.TasteWeight,
|
||||||
|
ContextTimeWeight: w.ContextTimeWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tasteTuningResp struct {
|
||||||
|
HalfLifeDays float64 `json:"half_life_days"`
|
||||||
|
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||||
|
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||||
|
EngagementFull float64 `json:"engagement_full"`
|
||||||
|
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
||||||
|
EraScale float64 `json:"era_scale"`
|
||||||
|
MoodScale float64 `json:"mood_scale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||||
|
return tasteTuningResp{
|
||||||
|
HalfLifeDays: t.HalfLifeDays,
|
||||||
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
|
EngagementFull: t.EngagementFull,
|
||||||
|
EnrichedTagScale: t.EnrichedTagScale,
|
||||||
|
EraScale: t.EraScale,
|
||||||
|
MoodScale: t.MoodScale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverTuningResp is the Discover scope on the wire (#2377).
|
||||||
|
type discoverTuningResp struct {
|
||||||
|
TagOverlapWeight float64 `json:"tag_overlap_weight"`
|
||||||
|
SnoozeDays float64 `json:"snooze_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverRespFrom(d recsettings.DiscoverTuning) discoverTuningResp {
|
||||||
|
return discoverTuningResp{
|
||||||
|
TagOverlapWeight: d.TagOverlapWeight,
|
||||||
|
SnoozeDays: d.SnoozeDays,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuningSnapshot is both the GET response and the post-mutation echo:
|
||||||
|
// current values alongside shipped defaults so the card can mark
|
||||||
|
// which knobs deviate.
|
||||||
|
type tuningSnapshot struct {
|
||||||
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Discover discoverTuningResp `json:"discover"`
|
||||||
|
Shipped struct {
|
||||||
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Discover discoverTuningResp `json:"discover"`
|
||||||
|
} `json:"shipped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) tuningSnapshot() tuningSnapshot {
|
||||||
|
var out tuningSnapshot
|
||||||
|
out.Profiles = map[string]weightsResp{
|
||||||
|
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
|
||||||
|
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||||
|
}
|
||||||
|
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||||
|
out.Discover = discoverRespFrom(h.recSettings.Discover())
|
||||||
|
out.Shipped.Profiles = map[string]weightsResp{
|
||||||
|
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||||
|
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||||
|
}
|
||||||
|
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||||
|
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
|
||||||
|
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchTuningReq carries the partial update: field name → new value,
|
||||||
|
// using the same snake_case names the GET response emits.
|
||||||
|
type patchTuningReq struct {
|
||||||
|
Values map[string]float64 `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePatchRecommendationTuning implements
|
||||||
|
// PATCH /api/admin/recommendation-tuning/{scope}.
|
||||||
|
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := chi.URLParam(r, "scope")
|
||||||
|
var body patchTuningReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Values) == 0 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch scope {
|
||||||
|
case recsettings.ScopeTaste:
|
||||||
|
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
||||||
|
case recsettings.ScopeDiscover:
|
||||||
|
err = h.recSettings.UpdateDiscover(r.Context(), body.Values)
|
||||||
|
default:
|
||||||
|
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeTuningErr(w, h, scope, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetRecommendationTuning implements
|
||||||
|
// POST /api/admin/recommendation-tuning/{scope}/reset.
|
||||||
|
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := chi.URLParam(r, "scope")
|
||||||
|
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
|
||||||
|
writeTuningErr(w, h, scope, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTuningErr maps recsettings validation errors to 400s and
|
||||||
|
// everything else to a logged 500.
|
||||||
|
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, recsettings.ErrUnknownScope):
|
||||||
|
writeErr(w, &apierror.Error{
|
||||||
|
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
|
||||||
|
})
|
||||||
|
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
|
||||||
|
default:
|
||||||
|
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("tuning update failed", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTuningRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||||
|
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||||
|
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
|
||||||
|
t.Helper()
|
||||||
|
var snap tuningSnapshot
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTuningRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
snap := decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
|
||||||
|
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
|
||||||
|
}
|
||||||
|
if snap.Taste.HalfLifeDays != 75 {
|
||||||
|
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
|
||||||
|
}
|
||||||
|
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
|
||||||
|
t.Error("untouched values must equal shipped defaults")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
r := newTuningRouter(h)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
|
||||||
|
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
snap := decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 2.5 {
|
||||||
|
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
|
||||||
|
}
|
||||||
|
// The change is live for the radio scoring path.
|
||||||
|
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
|
||||||
|
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("reset status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
snap = decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 1.0 {
|
||||||
|
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_PatchErrors(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
r := newTuningRouter(h)
|
||||||
|
cases := []struct {
|
||||||
|
name, path, body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"unknown scope", "/api/admin/recommendation-tuning/banana",
|
||||||
|
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
|
||||||
|
{"unknown field", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{"values":{"vibes":1}}`, http.StatusBadRequest},
|
||||||
|
{"out of range", "/api/admin/recommendation-tuning/taste",
|
||||||
|
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
|
||||||
|
{"empty values", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{"values":{}}`, http.StatusBadRequest},
|
||||||
|
{"bad json", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{`, http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != c.want {
|
||||||
|
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagSourceResp is the wire shape for one tag-enrichment provider in the
|
||||||
|
// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface
|
||||||
|
// (admin_cover_sources.go) — a separate, independent settings card so a new
|
||||||
|
// tag source is added without touching art settings (#1490).
|
||||||
|
type tagSourceResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
RequiresAPIKey bool `json:"requires_api_key"`
|
||||||
|
Supports []string `json:"supports"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
APIKeySet bool `json:"api_key_set"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
|
Testable bool `json:"testable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagSourceRespFrom(info tags.ProviderInfo) tagSourceResp {
|
||||||
|
supports := info.Supports
|
||||||
|
if supports == nil {
|
||||||
|
supports = []string{}
|
||||||
|
}
|
||||||
|
return tagSourceResp{
|
||||||
|
ID: info.ID,
|
||||||
|
DisplayName: info.DisplayName,
|
||||||
|
RequiresAPIKey: info.RequiresAPIKey,
|
||||||
|
Supports: supports,
|
||||||
|
Enabled: info.Enabled,
|
||||||
|
APIKeySet: info.APIKeySet,
|
||||||
|
DisplayOrder: info.DisplayOrder,
|
||||||
|
Testable: info.Testable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagSourcesListResp struct {
|
||||||
|
Providers []tagSourceResp `json:"providers"`
|
||||||
|
SourcesVersion int32 `json:"sources_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListTagSources implements GET /api/admin/tag-sources.
|
||||||
|
func (h *handlers) handleListTagSources(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
infos := h.tagSettings.ListProviderInfo()
|
||||||
|
out := tagSourcesListResp{
|
||||||
|
Providers: make([]tagSourceResp, 0, len(infos)),
|
||||||
|
SourcesVersion: h.tagSettings.CurrentVersion(),
|
||||||
|
}
|
||||||
|
for _, info := range infos {
|
||||||
|
out.Providers = append(out.Providers, tagSourceRespFrom(info))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTagSourceReq is the PATCH body. nil pointers mean "leave
|
||||||
|
// unchanged"; non-nil sets (empty api_key clears).
|
||||||
|
type updateTagSourceReq struct {
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
APIKey *string `json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateTagSourceResp struct {
|
||||||
|
tagSourceResp
|
||||||
|
VersionBumped bool `json:"version_bumped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateTagSource implements PATCH /api/admin/tag-sources/{provider_id}.
|
||||||
|
func (h *handlers) handleUpdateTagSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "provider_id")
|
||||||
|
|
||||||
|
var body updateTagSourceReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bumped, err := h.tagSettings.UpdateProvider(r.Context(), id, tags.ProviderUpdatePatch{
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
APIKey: body.APIKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, tags.ErrProviderNotFound) {
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin: update tag source", "id", id, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("update failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var found tagSourceResp
|
||||||
|
for _, info := range h.tagSettings.ListProviderInfo() {
|
||||||
|
if info.ID == id {
|
||||||
|
found = tagSourceRespFrom(info)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, updateTagSourceResp{tagSourceResp: found, VersionBumped: bumped})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testTagSourceResp struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test.
|
||||||
|
func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "provider_id")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := h.tagSettings.TestProvider(r.Context(), id)
|
||||||
|
duration := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
writeJSON(w, http.StatusOK, testTagSourceResp{OK: true, DurationMs: duration})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, tags.ErrProviderNotFound) {
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not testable, or the test failed — both surface as ok=false with a
|
||||||
|
// 200 (the operation completed; the result is data, not an error).
|
||||||
|
writeJSON(w, http.StatusOK, testTagSourceResp{OK: false, DurationMs: duration, Error: err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type researchTagsResp struct {
|
||||||
|
SourcesVersion int32 `json:"sources_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResearchTags implements POST /api/admin/tag-sources/research: bump
|
||||||
|
// the sources version so every settled ('none') track becomes eligible for
|
||||||
|
// a re-enrichment pass, then the background worker re-processes them.
|
||||||
|
func (h *handlers) handleResearchTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
newVer, err := h.tagSettings.BumpVersion(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: research tags", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("research failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, researchTagsResp{SourcesVersion: newVer})
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apiTestTagProvider is a minimal TrackTagProvider for api-package tests.
|
||||||
|
// Does not implement TestableProvider.
|
||||||
|
type apiTestTagProvider struct {
|
||||||
|
id string
|
||||||
|
display string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiTestTagProvider) ID() string { return p.id }
|
||||||
|
func (p *apiTestTagProvider) DisplayName() string { return p.display }
|
||||||
|
func (p *apiTestTagProvider) RequiresAPIKey() bool { return false }
|
||||||
|
func (p *apiTestTagProvider) DefaultEnabled() bool { return true }
|
||||||
|
func (p *apiTestTagProvider) Configure(_ tags.ProviderSettings) error { return nil }
|
||||||
|
func (p *apiTestTagProvider) FetchTrackTags(_ context.Context, _ tags.TrackRef) ([]tags.Tag, error) {
|
||||||
|
return []tags.Tag{{Name: "x", Weight: 1}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiTestTestableTagProvider also implements TestableProvider.
|
||||||
|
type apiTestTestableTagProvider struct {
|
||||||
|
apiTestTagProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiTestTestableTagProvider) TestConnection(_ context.Context) error { return nil }
|
||||||
|
|
||||||
|
func newAdminTagSourcesRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/api/admin", func(admin chi.Router) {
|
||||||
|
admin.Use(auth.RequireAdmin())
|
||||||
|
admin.Get("/tag-sources", h.handleListTagSources)
|
||||||
|
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
||||||
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// testHandlersWithTagSettings installs a tags.SettingsService backed by the
|
||||||
|
// test DB. Register the needed fake providers BEFORE calling this, and pair
|
||||||
|
// with tags.ResetRegistryForTests in t.Cleanup.
|
||||||
|
func testHandlersWithTagSettings(t *testing.T) *handlers {
|
||||||
|
t.Helper()
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
s, err := tags.NewSettingsService(context.Background(), pool, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService: %v", err)
|
||||||
|
}
|
||||||
|
h.tagSettings = s
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminListTagSources_ReturnsRegisteredProviders(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "test-tag-prov", display: "Test Tag Provider"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tslist", "pw", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/tag-sources", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp tagSourcesListResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Providers) != 1 || resp.Providers[0].ID != "test-tag-prov" {
|
||||||
|
t.Fatalf("providers = %+v, want 1 (test-tag-prov)", resp.Providers)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, s := range resp.Providers[0].Supports {
|
||||||
|
if s == "track_tags" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("supports = %v, want to include track_tags", resp.Providers[0].Supports)
|
||||||
|
}
|
||||||
|
if resp.Providers[0].Testable {
|
||||||
|
t.Error("testable = true, want false (no TestConnection)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_FlippingEnabledBumpsVersion(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "flip-tag", display: "Flip Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tsflip", "pw", true)
|
||||||
|
|
||||||
|
disabled := false
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{Enabled: &disabled})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/flip-tag", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp updateTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.VersionBumped {
|
||||||
|
t.Error("version_bumped = false, want true after flipping enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_KeyOnlyDoesNotBump(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "key-tag", display: "Key Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tskey", "pw", true)
|
||||||
|
|
||||||
|
newKey := "newkey"
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{APIKey: &newKey})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/key-tag", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp updateTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.VersionBumped {
|
||||||
|
t.Error("version_bumped = true, want false for key-only change")
|
||||||
|
}
|
||||||
|
if !resp.APIKeySet {
|
||||||
|
t.Error("api_key_set = false, want true after setting key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_UnknownProvider404(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "ts404", "pw", true)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/missing", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestTagSource_NonAdminReturns403(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTestableTagProvider{apiTestTagProvider{id: "testable-tag", display: "Testable Tag"}})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
nonAdmin := seedUser(t, h.pool, "tsnoadmin", "pw", false)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/testable-tag/test", nil)
|
||||||
|
req = withUser(req, nonAdmin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestTagSource_NotTestableReturnsOkFalse(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "nontestable-tag", display: "Non-Testable Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tsntest", "pw", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/nontestable-tag/test", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp testTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.OK {
|
||||||
|
t.Error("ok = true, want false for non-testable provider")
|
||||||
|
}
|
||||||
|
if resp.Error == "" {
|
||||||
|
t.Error("error string empty, want a message")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,18 +20,22 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/netsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte, netSettings *netsettings.Service) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
|
recSettings: recSettings,
|
||||||
rng: rng.Float64,
|
rng: rng.Float64,
|
||||||
lidarrCfg: lidarrCfg,
|
lidarrCfg: lidarrCfg,
|
||||||
lidarrRequests: lidarrReqs,
|
lidarrRequests: lidarrReqs,
|
||||||
@@ -40,6 +44,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
playlists: playlistsSvc,
|
playlists: playlistsSvc,
|
||||||
coverart: coverEnricher,
|
coverart: coverEnricher,
|
||||||
coverSettings: coverSettings,
|
coverSettings: coverSettings,
|
||||||
|
tagSettings: tagSettings,
|
||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
scanCfg: scanCfg,
|
scanCfg: scanCfg,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
@@ -47,6 +52,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
eventbus: bus,
|
eventbus: bus,
|
||||||
playlistScheduler: playlistScheduler,
|
playlistScheduler: playlistScheduler,
|
||||||
streamSecret: streamSecret,
|
streamSecret: streamSecret,
|
||||||
|
netSettings: netSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Route("/api", func(api chi.Router) {
|
r.Route("/api", func(api chi.Router) {
|
||||||
@@ -70,7 +76,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
api.With(auth.OptionalUser(pool, logger)).Get("/tracks/{id}/stream.{ext}", h.handleGetStream)
|
||||||
|
|
||||||
api.Group(func(authed chi.Router) {
|
api.Group(func(authed chi.Router) {
|
||||||
authed.Use(auth.RequireUser(pool))
|
authed.Use(auth.RequireUser(pool, netSettings.Hops))
|
||||||
authed.Post("/auth/logout", h.handleLogout)
|
authed.Post("/auth/logout", h.handleLogout)
|
||||||
authed.Get("/me", h.handleGetMe)
|
authed.Get("/me", h.handleGetMe)
|
||||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
||||||
@@ -83,6 +89,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
authed.Put("/me/timezone", h.handlePutTimezone)
|
||||||
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
authed.Get("/me/api-token", h.handleGetMyAPIToken)
|
||||||
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||||
|
authed.Get("/me/sessions", h.handleListMySessions)
|
||||||
|
authed.Delete("/me/sessions/{id}", h.handleRevokeMySession)
|
||||||
|
authed.Post("/me/sessions/logout-others", h.handleRevokeMyOtherSessions)
|
||||||
|
|
||||||
authed.Get("/artists", h.handleListArtists)
|
authed.Get("/artists", h.handleListArtists)
|
||||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||||
@@ -93,6 +102,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||||
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||||
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
authed.Get("/library/albums", h.handleListLibraryAlbums)
|
||||||
|
// Browse indexes (#367). Genre filtering rides
|
||||||
|
// /library/albums?genre= rather than a path segment, because raw
|
||||||
|
// ID3 genres contain slashes ("Rock/Pop") that a path can't carry.
|
||||||
|
authed.Get("/library/genres", h.handleListGenres)
|
||||||
|
authed.Get("/library/years", h.handleListAlbumYears)
|
||||||
authed.Get("/library/sync", h.handleLibrarySync)
|
authed.Get("/library/sync", h.handleLibrarySync)
|
||||||
authed.Get("/tracks/{id}", h.handleGetTrack)
|
authed.Get("/tracks/{id}", h.handleGetTrack)
|
||||||
// /tracks/{id}/stream is mounted above with OptionalUser so
|
// /tracks/{id}/stream is mounted above with OptionalUser so
|
||||||
@@ -100,6 +114,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/search", h.handleSearch)
|
authed.Get("/search", h.handleSearch)
|
||||||
authed.Get("/radio", h.handleRadio)
|
authed.Get("/radio", h.handleRadio)
|
||||||
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
||||||
|
// Snooze = "not right now", time-boxed and self-expiring
|
||||||
|
// (#2374). Not a dislike — see the migration for why.
|
||||||
|
authed.Post("/discover/suggestions/{mbid}/snooze", h.handleSnoozeSuggestion)
|
||||||
|
authed.Delete("/discover/suggestions/{mbid}/snooze", h.handleUnsnoozeSuggestion)
|
||||||
|
authed.Get("/discover/snoozes", h.handleListSuggestionSnoozes)
|
||||||
authed.Get("/home", h.handleGetHome)
|
authed.Get("/home", h.handleGetHome)
|
||||||
authed.Get("/home/index", h.handleGetHomeIndex)
|
authed.Get("/home/index", h.handleGetHomeIndex)
|
||||||
authed.Post("/events", h.handleEvents)
|
authed.Post("/events", h.handleEvents)
|
||||||
@@ -173,6 +192,9 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Post("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover)
|
admin.Post("/albums/{id}/cover/refetch", h.handleAdminAlbumRefetchCover)
|
||||||
admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers)
|
admin.Post("/covers/refetch-missing", h.handleAdminBulkRefetchCovers)
|
||||||
|
|
||||||
|
admin.Get("/network-settings", h.handleGetNetworkSettings)
|
||||||
|
admin.Put("/network-settings", h.handleUpdateNetworkSettings)
|
||||||
|
|
||||||
admin.Get("/scan/status", h.handleGetScanStatus)
|
admin.Get("/scan/status", h.handleGetScanStatus)
|
||||||
admin.Post("/scan/run", h.handleTriggerScan)
|
admin.Post("/scan/run", h.handleTriggerScan)
|
||||||
|
|
||||||
@@ -198,9 +220,22 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
||||||
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
||||||
|
|
||||||
|
admin.Get("/tag-sources", h.handleListTagSources)
|
||||||
|
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
||||||
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||||
|
admin.Post("/tag-sources/research", h.handleResearchTags)
|
||||||
|
|
||||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||||
|
|
||||||
|
// Recommendation tuning lab (#1250): scoring-weight
|
||||||
|
// profiles + taste-build knobs, DB-backed, live effect.
|
||||||
|
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||||
|
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||||
|
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||||
|
// Weekly outcome trends + knob-turn markers (#1251).
|
||||||
|
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
|
||||||
})
|
})
|
||||||
|
|
||||||
authed.Get("/playlists", h.handleListPlaylists)
|
authed.Get("/playlists", h.handleListPlaylists)
|
||||||
@@ -223,6 +258,7 @@ type handlers struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
events *playevents.Writer
|
events *playevents.Writer
|
||||||
recCfg config.RecommendationConfig
|
recCfg config.RecommendationConfig
|
||||||
|
recSettings *recsettings.Service
|
||||||
rng func() float64
|
rng func() float64
|
||||||
lidarrCfg *lidarrconfig.Service
|
lidarrCfg *lidarrconfig.Service
|
||||||
lidarrRequests *lidarrrequests.Service
|
lidarrRequests *lidarrrequests.Service
|
||||||
@@ -231,12 +267,16 @@ type handlers struct {
|
|||||||
playlists *playlists.Service
|
playlists *playlists.Service
|
||||||
coverart *coverart.Enricher
|
coverart *coverart.Enricher
|
||||||
coverSettings *coverart.SettingsService
|
coverSettings *coverart.SettingsService
|
||||||
|
tagSettings *tags.SettingsService
|
||||||
scanner *library.Scanner
|
scanner *library.Scanner
|
||||||
scanCfg library.RunScanConfig
|
scanCfg library.RunScanConfig
|
||||||
dataDir string
|
dataDir string
|
||||||
mailer mailer.Sender
|
mailer mailer.Sender
|
||||||
eventbus *eventbus.Bus
|
eventbus *eventbus.Bus
|
||||||
playlistScheduler *playlists.Scheduler
|
playlistScheduler *playlists.Scheduler
|
||||||
|
// netSettings caches the trusted reverse-proxy depth read by the auth
|
||||||
|
// middleware on every request and edited from the admin network card.
|
||||||
|
netSettings *netsettings.Service
|
||||||
// streamSecret is the HMAC key used by SignStreamToken /
|
// streamSecret is the HMAC key used by SignStreamToken /
|
||||||
// VerifyStreamToken to authenticate the UPnP-speaker stream path
|
// VerifyStreamToken to authenticate the UPnP-speaker stream path
|
||||||
// (see internal/api/stream_token.go and the design at
|
// (see internal/api/stream_token.go and the design at
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ func (h *handlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
TokenHash: auth.HashSessionToken(token),
|
TokenHash: auth.HashSessionToken(token),
|
||||||
UserAgent: r.UserAgent(),
|
UserAgent: r.UserAgent(),
|
||||||
|
// Origin address, frozen at issue time. Compared against last_ip in
|
||||||
|
// the active-sessions surface: a session that was born somewhere the
|
||||||
|
// user recognises but is being used from somewhere they don't is the
|
||||||
|
// case this whole surface exists to surface.
|
||||||
|
Ip: auth.ClientIP(r, h.netSettings.Hops()),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
h.logger.Error("api: insert session failed", "err", err)
|
h.logger.Error("api: insert session failed", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("insert failed", err))
|
writeErr(w, apierror.InternalMsg("insert failed", err))
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ func (h *handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
TokenHash: auth.HashSessionToken(sessionToken),
|
TokenHash: auth.HashSessionToken(sessionToken),
|
||||||
UserAgent: r.UserAgent(),
|
UserAgent: r.UserAgent(),
|
||||||
|
Ip: auth.ClientIP(r, h.netSettings.Hops()),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
h.logger.Error("register: insert session failed", "err", err)
|
h.logger.Error("register: insert session failed", "err", err)
|
||||||
writeErr(w, apierror.Internal(err))
|
writeErr(w, apierror.Internal(err))
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,11 +57,12 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dbtest.ResetDB(t, pool)
|
dbtest.ResetDB(t, pool)
|
||||||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||||
recCfg := config.RecommendationConfig{
|
recCfg := config.RecommendationConfig{
|
||||||
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
|
||||||
SkipPenalty: 1.0, JitterMagnitude: 0.1,
|
|
||||||
ContextWeight: 2.0, SimilarityWeight: 2.0,
|
|
||||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||||
}
|
}
|
||||||
|
recSettings, err := recsettings.New(context.Background(), pool, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recsettings: %v", err)
|
||||||
|
}
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
lidarrCfg := lidarrconfig.New(pool)
|
||||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
||||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
||||||
@@ -70,7 +72,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
||||||
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
||||||
return h, pool
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,3 +183,13 @@ func parsePaging(raw url.Values) (limit, offset int, err error) {
|
|||||||
}
|
}
|
||||||
return limit, offset, nil
|
return limit, offset, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nonNilStrings guarantees a JSON array rather than null. The clients iterate
|
||||||
|
// these without a null check, matching how every other list field in this
|
||||||
|
// package is emitted.
|
||||||
|
func nonNilStrings(in []string) []string {
|
||||||
|
if in == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
return in
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -24,6 +25,33 @@ type eventRequest struct {
|
|||||||
// / "discover" feed the system-playlist rotation dedup (#415);
|
// / "discover" feed the system-playlist rotation dedup (#415);
|
||||||
// absent / "" for library, user-playlist, radio, Subsonic.
|
// absent / "" for library, user-playlist, radio, Subsonic.
|
||||||
Source *string `json:"source"`
|
Source *string `json:"source"`
|
||||||
|
// DeviceClass is the client's self-reported device class (#1551),
|
||||||
|
// normalized + stored per play to condition radio on the current device.
|
||||||
|
DeviceClass *string `json:"device_class"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownDeviceClasses whitelists the device classes the context-affinity facet
|
||||||
|
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
|
||||||
|
// "other", and empty/absent to "" (stored NULL → excluded from the device
|
||||||
|
// dimension). Kept permissive (no DB CHECK) so a new client class is one
|
||||||
|
// whitelist entry, not a migration.
|
||||||
|
var knownDeviceClasses = map[string]bool{
|
||||||
|
"mobile": true, "tablet": true, "desktop": true,
|
||||||
|
"web": true, "tv": true, "watch": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDeviceClass(dc *string) string {
|
||||||
|
if dc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := strings.ToLower(strings.TrimSpace(*dc))
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if knownDeviceClasses[s] {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "other"
|
||||||
}
|
}
|
||||||
|
|
||||||
type playStartedResponse struct {
|
type playStartedResponse struct {
|
||||||
@@ -31,6 +59,26 @@ type playStartedResponse struct {
|
|||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eventFutureSkew is the tolerated client-clock drift into the future
|
||||||
|
// before a timestamp is treated as bogus and replaced with now.
|
||||||
|
const eventFutureSkew = 5 * time.Minute
|
||||||
|
|
||||||
|
// clampEventTime bounds a client-supplied event timestamp to sanity:
|
||||||
|
// no earlier than the account's creation (offline replays can
|
||||||
|
// legitimately be days old, but no play can predate the user) and no
|
||||||
|
// later than now + a small skew allowance. Unbounded client clocks
|
||||||
|
// previously let a skewed device write arbitrarily old plays, which
|
||||||
|
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
|
||||||
|
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
|
||||||
|
if at.Before(userCreatedAt) {
|
||||||
|
return userCreatedAt
|
||||||
|
}
|
||||||
|
if at.After(now.Add(eventFutureSkew)) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
type okResponse struct {
|
type okResponse struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
}
|
}
|
||||||
@@ -52,7 +100,7 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
|
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
at = parsed
|
at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC())
|
||||||
}
|
}
|
||||||
clientID := ""
|
clientID := ""
|
||||||
if req.ClientID != nil {
|
if req.ClientID != nil {
|
||||||
@@ -94,7 +142,9 @@ func (h *handlers) handleEventPlayStarted(
|
|||||||
if req.Source != nil {
|
if req.Source != nil {
|
||||||
source = *req.Source
|
source = *req.Source
|
||||||
}
|
}
|
||||||
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
|
deviceClass := normalizeDeviceClass(req.DeviceClass)
|
||||||
|
res, err := h.events.RecordPlayStartedWithSource(
|
||||||
|
r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: events: play_started", "err", err)
|
h.logger.Error("api: events: play_started", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||||
@@ -139,7 +189,8 @@ func (h *handlers) handleEventPlayOffline(
|
|||||||
source = *req.Source
|
source = *req.Source
|
||||||
}
|
}
|
||||||
if err := h.events.RecordOfflinePlay(
|
if err := h.events.RecordOfflinePlay(
|
||||||
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
|
r.Context(), user.ID, trackID, clientID, source,
|
||||||
|
normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
h.logger.Error("api: events: play_offline", "err", err)
|
h.logger.Error("api: events: play_offline", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||||
|
|||||||
@@ -82,9 +82,17 @@ func (h *handlers) handleGetAlbum(w http.ResponseWriter, r *http.Request) {
|
|||||||
refs = append(refs, ref)
|
refs = append(refs, ref)
|
||||||
durSec += ref.DurationSec
|
durSec += ref.DurationSec
|
||||||
}
|
}
|
||||||
|
// Genre chips are a navigation nicety, so a failure here must not 404 an
|
||||||
|
// album that loaded fine. Log and ship the detail without them.
|
||||||
|
genres, err := q.ListGenresForAlbum(r.Context(), album.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("api: list album genres failed", "err", err, "album_id", uuidToString(album.ID))
|
||||||
|
genres = nil
|
||||||
|
}
|
||||||
detail := AlbumDetail{
|
detail := AlbumDetail{
|
||||||
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
|
AlbumRef: albumRefFrom(album, artistName, len(tracks), durSec),
|
||||||
Tracks: refs,
|
Tracks: refs,
|
||||||
|
Genres: nonNilStrings(genres),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, detail)
|
writeJSON(w, http.StatusOK, detail)
|
||||||
}
|
}
|
||||||
@@ -114,9 +122,15 @@ func (h *handlers) handleGetArtist(w http.ResponseWriter, r *http.Request) {
|
|||||||
// durationSec=0: not aggregated for nested album lists per spec data flow.
|
// durationSec=0: not aggregated for nested album lists per spec data flow.
|
||||||
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
|
refs = append(refs, albumRefFrom(row.Album, artist.Name, int(row.TrackCount), 0))
|
||||||
}
|
}
|
||||||
|
genres, err := q.ListGenresForArtist(r.Context(), artist.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Warn("api: list artist genres failed", "err", err, "artist_id", uuidToString(artist.ID))
|
||||||
|
genres = nil
|
||||||
|
}
|
||||||
detail := ArtistDetail{
|
detail := ArtistDetail{
|
||||||
ArtistRef: artistRefFrom(artist, len(rows)),
|
ArtistRef: artistRefFrom(artist, len(rows)),
|
||||||
Albums: refs,
|
Albums: refs,
|
||||||
|
Genres: nonNilStrings(genres),
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, detail)
|
writeJSON(w, http.StatusOK, detail)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,189 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Widest plausible bounds for an open-ended year filter. A missing year_from
|
||||||
|
// means "from the beginning" rather than "from year zero of the query", and
|
||||||
|
// likewise for year_to, so the caller can filter on one edge only.
|
||||||
|
const (
|
||||||
|
minBrowseYear = 0
|
||||||
|
maxBrowseYear = 9999
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errBadYear = errors.New("year_from and year_to must be integers")
|
||||||
|
errInvertedYearRange = errors.New("year_from must not be greater than year_to")
|
||||||
|
)
|
||||||
|
|
||||||
|
// yearFilter carries a parsed, validated inclusive year range. active is false
|
||||||
|
// when the request asked for no year filtering at all — distinct from a range
|
||||||
|
// that happens to cover everything, because the two take different code paths.
|
||||||
|
type yearFilter struct {
|
||||||
|
from int32
|
||||||
|
to int32
|
||||||
|
active bool
|
||||||
|
}
|
||||||
|
|
||||||
// handleListLibraryAlbums implements GET /api/library/albums. Mirrors
|
// handleListLibraryAlbums implements GET /api/library/albums. Mirrors
|
||||||
// /api/artists?sort=alpha but for albums. The new wrapping-grid page on
|
// /api/artists?sort=alpha but for albums. The new wrapping-grid page on
|
||||||
// the SPA infinite-scrolls against this endpoint via TanStack
|
// the SPA infinite-scrolls against this endpoint via TanStack
|
||||||
// createInfiniteQuery.
|
// createInfiniteQuery.
|
||||||
|
//
|
||||||
|
// Optional filters (#367): `genre` and `year_from`/`year_to`.
|
||||||
|
//
|
||||||
|
// Genre arrives as a QUERY parameter rather than a path segment on purpose.
|
||||||
|
// Raw ID3 genres routinely contain a slash — "Rock/Pop" is a real tag, and
|
||||||
|
// the one the task itself cites — which cannot survive a path segment: Go
|
||||||
|
// normalises %2F and the router would split the value into two segments.
|
||||||
func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
|
||||||
limit, offset, err := parsePaging(r.URL.Query())
|
limit, offset, err := parsePaging(r.URL.Query())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := dbq.New(h.pool)
|
genre := strings.TrimSpace(r.URL.Query().Get("genre"))
|
||||||
rows, err := q.ListAlbumsAlphaWithArtist(r.Context(), dbq.ListAlbumsAlphaWithArtistParams{
|
years, err := parseYearFilter(r.URL.Query())
|
||||||
Limit: int32(limit), Offset: int32(offset),
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: list library albums", "err", err)
|
writeErr(w, apierror.BadRequest("bad_request", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if genre != "" && years.active {
|
||||||
|
// Refused rather than silently honouring one: the UI browses these as
|
||||||
|
// separate axes (a genres page, a year filter on the albums page), so
|
||||||
|
// the combination can only arrive from a caller that has misunderstood
|
||||||
|
// the contract — and quietly dropping half a filter would report a
|
||||||
|
// narrower result set than it actually returned.
|
||||||
|
writeErr(w, apierror.BadRequest("unsupported_filter_combination",
|
||||||
|
"genre and year filters cannot be combined"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
var (
|
||||||
|
items []AlbumRef
|
||||||
|
total int64
|
||||||
|
)
|
||||||
|
switch {
|
||||||
|
case genre != "":
|
||||||
|
items, total, err = albumsByGenre(r.Context(), q, genre, limit, offset)
|
||||||
|
case years.active:
|
||||||
|
items, total, err = albumsByYear(r.Context(), q, years, limit, offset)
|
||||||
|
default:
|
||||||
|
items, total, err = albumsAlpha(r.Context(), q, limit, offset)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list library albums", "err", err, "genre", genre, "years", years.active)
|
||||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
total, err := q.CountAlbums(r.Context())
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("api: count albums", "err", err)
|
|
||||||
writeErr(w, apierror.InternalMsg("count failed", err))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items := make([]AlbumRef, 0, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, Page[AlbumRef]{
|
writeJSON(w, http.StatusOK, Page[AlbumRef]{
|
||||||
Items: items, Total: int(total), Limit: limit, Offset: offset,
|
Items: items, Total: int(total), Limit: limit, Offset: offset,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func albumsAlpha(
|
||||||
|
ctx context.Context, q *dbq.Queries, limit, offset int,
|
||||||
|
) ([]AlbumRef, int64, error) {
|
||||||
|
rows, err := q.ListAlbumsAlphaWithArtist(ctx, dbq.ListAlbumsAlphaWithArtistParams{
|
||||||
|
Limit: int32(limit), Offset: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total, err := q.CountAlbums(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items := make([]AlbumRef, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func albumsByGenre(
|
||||||
|
ctx context.Context, q *dbq.Queries, genre string, limit, offset int,
|
||||||
|
) ([]AlbumRef, int64, error) {
|
||||||
|
rows, err := q.ListAlbumsByGenreWithArtist(ctx, dbq.ListAlbumsByGenreWithArtistParams{
|
||||||
|
Genre: genre, Lim: int32(limit), Off: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total, err := q.CountAlbumsByGenre(ctx, genre)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items := make([]AlbumRef, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func albumsByYear(
|
||||||
|
ctx context.Context, q *dbq.Queries, years yearFilter, limit, offset int,
|
||||||
|
) ([]AlbumRef, int64, error) {
|
||||||
|
rows, err := q.ListAlbumsByYearRangeWithArtist(ctx,
|
||||||
|
dbq.ListAlbumsByYearRangeWithArtistParams{
|
||||||
|
YearFrom: years.from, YearTo: years.to,
|
||||||
|
Lim: int32(limit), Off: int32(offset),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
total, err := q.CountAlbumsByYearRange(ctx, dbq.CountAlbumsByYearRangeParams{
|
||||||
|
YearFrom: years.from, YearTo: years.to,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items := make([]AlbumRef, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
items = append(items, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseYearFilter reads year_from / year_to. Either may be omitted, which
|
||||||
|
// leaves that edge open — filtering "everything before 1990" shouldn't
|
||||||
|
// require inventing a lower bound.
|
||||||
|
func parseYearFilter(raw url.Values) (yearFilter, error) {
|
||||||
|
fromRaw := strings.TrimSpace(raw.Get("year_from"))
|
||||||
|
toRaw := strings.TrimSpace(raw.Get("year_to"))
|
||||||
|
if fromRaw == "" && toRaw == "" {
|
||||||
|
return yearFilter{}, nil
|
||||||
|
}
|
||||||
|
f := yearFilter{from: minBrowseYear, to: maxBrowseYear, active: true}
|
||||||
|
if fromRaw != "" {
|
||||||
|
n, err := strconv.Atoi(fromRaw)
|
||||||
|
if err != nil {
|
||||||
|
return yearFilter{}, errBadYear
|
||||||
|
}
|
||||||
|
f.from = int32(n)
|
||||||
|
}
|
||||||
|
if toRaw != "" {
|
||||||
|
n, err := strconv.Atoi(toRaw)
|
||||||
|
if err != nil {
|
||||||
|
return yearFilter{}, errBadYear
|
||||||
|
}
|
||||||
|
f.to = int32(n)
|
||||||
|
}
|
||||||
|
if f.from > f.to {
|
||||||
|
// Rejected rather than swapped: silently reordering would return
|
||||||
|
// results for a range the caller didn't ask for, and an inverted
|
||||||
|
// range is far more likely a bug than an intent.
|
||||||
|
return yearFilter{}, errInvertedYearRange
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// genreCount is one row of the genre browse index (#367).
|
||||||
|
//
|
||||||
|
// Genres are the raw ID3 strings, split on [;,] but otherwise untouched — no
|
||||||
|
// case folding and no synonym mapping. So "Rock" and "rock" can both appear,
|
||||||
|
// as can "Rock/Pop" alongside "Rock" and "Pop". That's deliberate for v1: the
|
||||||
|
// alternative is a normalisation table to invent and maintain, and the raw
|
||||||
|
// spread has to be visible before anyone can judge whether it's a problem.
|
||||||
|
type genreCount struct {
|
||||||
|
Genre string `json:"genre"`
|
||||||
|
TrackCount int `json:"track_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// yearCount is one row of the year browse index.
|
||||||
|
type yearCount struct {
|
||||||
|
Year int `json:"year"`
|
||||||
|
AlbumCount int `json:"album_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListGenres implements GET /api/library/genres.
|
||||||
|
//
|
||||||
|
// Unpaged on purpose. Even a messy library yields hundreds of distinct tag
|
||||||
|
// strings, not thousands, and the client needs the whole set at once to render
|
||||||
|
// a browsable index — paging it would mean the UI could only ever show a
|
||||||
|
// prefix of an ordering the user didn't choose.
|
||||||
|
func (h *handlers) handleListGenres(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := dbq.New(h.pool).ListGenresWithCount(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list genres", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]genreCount, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, genreCount{Genre: row.Genre, TrackCount: int(row.TrackCount)})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListAlbumYears implements GET /api/library/years.
|
||||||
|
//
|
||||||
|
// Albums with no release_date are absent rather than bucketed under 0 — "year
|
||||||
|
// unknown" isn't a year, and inventing a row for it would put a fake entry at
|
||||||
|
// one end of a chronological list.
|
||||||
|
func (h *handlers) handleListAlbumYears(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rows, err := dbq.New(h.pool).ListAlbumYearsWithCount(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list album years", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]yearCount, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, yearCount{Year: int(row.Year), AlbumCount: int(row.AlbumCount)})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// parseYearFilter is pure, so this runs in the fast lane rather than waiting
|
||||||
|
// on the integration job.
|
||||||
|
func TestParseYearFilter(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
wantActive bool
|
||||||
|
wantFrom int32
|
||||||
|
wantTo int32
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{name: "no params means no filtering", query: "", wantActive: false},
|
||||||
|
{
|
||||||
|
name: "both bounds", query: "year_from=1990&year_to=1999",
|
||||||
|
wantActive: true, wantFrom: 1990, wantTo: 1999,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// "everything from 2000 onward" shouldn't require the caller to
|
||||||
|
// invent an upper bound.
|
||||||
|
name: "from only leaves the upper edge open", query: "year_from=2000",
|
||||||
|
wantActive: true, wantFrom: 2000, wantTo: maxBrowseYear,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "to only leaves the lower edge open", query: "year_to=1979",
|
||||||
|
wantActive: true, wantFrom: minBrowseYear, wantTo: 1979,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a single year is a degenerate range", query: "year_from=1985&year_to=1985",
|
||||||
|
wantActive: true, wantFrom: 1985, wantTo: 1985,
|
||||||
|
},
|
||||||
|
{name: "non-numeric from", query: "year_from=nineteen", wantErr: errBadYear},
|
||||||
|
{name: "non-numeric to", query: "year_to=x", wantErr: errBadYear},
|
||||||
|
{
|
||||||
|
// Rejected, not silently swapped — reordering would answer a
|
||||||
|
// question the caller didn't ask.
|
||||||
|
name: "inverted range", query: "year_from=2000&year_to=1990",
|
||||||
|
wantErr: errInvertedYearRange,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace-only values are treated as absent",
|
||||||
|
query: "year_from=%20&year_to=%20", wantActive: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
raw, err := url.ParseQuery(tc.query)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseQuery: %v", err)
|
||||||
|
}
|
||||||
|
got, gotErr := parseYearFilter(raw)
|
||||||
|
if tc.wantErr != nil {
|
||||||
|
if gotErr != tc.wantErr {
|
||||||
|
t.Fatalf("error = %v, want %v", gotErr, tc.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if gotErr != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", gotErr)
|
||||||
|
}
|
||||||
|
if got.active != tc.wantActive {
|
||||||
|
t.Errorf("active = %v, want %v", got.active, tc.wantActive)
|
||||||
|
}
|
||||||
|
if tc.wantActive && (got.from != tc.wantFrom || got.to != tc.wantTo) {
|
||||||
|
t.Errorf("range = [%d,%d], want [%d,%d]",
|
||||||
|
got.from, got.to, tc.wantFrom, tc.wantTo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The crux of #367: a track tagged "Rock;Pop" must be reachable from BOTH
|
||||||
|
// genres. An exact-string match — which is what ListAlbumsByGenre did before
|
||||||
|
// this task — makes every multi-genre track invisible from either of its
|
||||||
|
// genres, so the index would list a genre whose page is empty.
|
||||||
|
func TestListGenres_SplitsMultiGenreTags(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
artist := seedArtist(t, pool, "Genre Splitter")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "Split Album", 1995)
|
||||||
|
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Both Genres", 1, 200000, "Rock;Pop")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleListGenres(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var got []genreCount
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, g := range got {
|
||||||
|
counts[g.Genre] = g.TrackCount
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Rock", "Pop"} {
|
||||||
|
if counts[want] < 1 {
|
||||||
|
t.Errorf("genre %q missing from index (got %v)", want, counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The undivided string must NOT appear as its own genre.
|
||||||
|
if _, ok := counts["Rock;Pop"]; ok {
|
||||||
|
t.Error(`"Rock;Pop" surfaced as a single genre — the split didn't happen`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Splitting produces leading spaces on every fragment after the first, and
|
||||||
|
// showing " Pop" as a genre distinct from "Pop" would be a bug. Trimming is a
|
||||||
|
// repair for our own splitting, not normalisation of the operator's tags.
|
||||||
|
func TestListGenres_TrimsFragmentWhitespace(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
artist := seedArtist(t, pool, "Spacey Tags")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "Spacey Album", 2001)
|
||||||
|
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Spaced", 1, 200000, "Jazz; Blues ;")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/library/genres", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleListGenres(w, req)
|
||||||
|
|
||||||
|
var got []genreCount
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, g := range got {
|
||||||
|
seen[g.Genre] = true
|
||||||
|
if g.Genre == "" {
|
||||||
|
t.Error("empty genre in index — a trailing delimiter leaked through")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, want := range []string{"Jazz", "Blues"} {
|
||||||
|
if !seen[want] {
|
||||||
|
t.Errorf("genre %q missing (got %v)", want, keysOf(seen))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, unwanted := range []string{" Blues", "Blues ", " Blues "} {
|
||||||
|
if seen[unwanted] {
|
||||||
|
t.Errorf("untrimmed genre %q present", unwanted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genre filtering must agree with the index: every genre the index lists has
|
||||||
|
// to lead to a non-empty page, which is exactly what the old exact-match
|
||||||
|
// query could not guarantee.
|
||||||
|
func TestListLibraryAlbums_GenreFilterReachesMultiGenreTracks(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
artist := seedArtist(t, pool, "Reachable")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "Reachable Album", 1998)
|
||||||
|
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Multi", 1, 200000, "Rock;Pop")
|
||||||
|
|
||||||
|
for _, genre := range []string{"Rock", "Pop"} {
|
||||||
|
t.Run(genre, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/library/albums?genre="+url.QueryEscape(genre), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleListLibraryAlbums(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var page Page[AlbumRef]
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if page.Total < 1 {
|
||||||
|
t.Fatalf("total = %d, want >=1 — genre %q led to an empty page",
|
||||||
|
page.Total, genre)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, a := range page.Items {
|
||||||
|
if a.Title == "Reachable Album" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("seeded album absent from genre %q results", genre)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A genre containing a slash is why filtering is a query parameter rather
|
||||||
|
// than a path segment — "Rock/Pop" cannot survive a path.
|
||||||
|
func TestListLibraryAlbums_GenreWithSlashSurvives(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
artist := seedArtist(t, pool, "Slashed")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "Slashed Album", 2003)
|
||||||
|
seedTrackWithGenre(t, pool, album.ID, artist.ID, "Slashy", 1, 200000, "Rock/Pop")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/library/albums?genre="+url.QueryEscape("Rock/Pop"), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleListLibraryAlbums(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var page Page[AlbumRef]
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if page.Total < 1 {
|
||||||
|
t.Errorf(`total = %d, want >=1 for genre "Rock/Pop"`, page.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLibraryAlbums_YearRangeFilter(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
artist := seedArtist(t, pool, "Chronology")
|
||||||
|
seedAlbum(t, pool, artist.ID, "Old Record", 1972)
|
||||||
|
seedAlbum(t, pool, artist.ID, "Middle Record", 1995)
|
||||||
|
seedAlbum(t, pool, artist.ID, "New Record", 2020)
|
||||||
|
// An undated album must not appear in ANY year range.
|
||||||
|
seedAlbum(t, pool, artist.ID, "Undated Record", 0)
|
||||||
|
|
||||||
|
titles := func(query string) map[string]bool {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/library/albums?"+query, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleListLibraryAlbums(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d for %q, want 200", w.Code, query)
|
||||||
|
}
|
||||||
|
var page Page[AlbumRef]
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&page); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
out := map[string]bool{}
|
||||||
|
for _, a := range page.Items {
|
||||||
|
out[a.Title] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
got := titles("year_from=1990&year_to=2000&limit=200")
|
||||||
|
if !got["Middle Record"] {
|
||||||
|
t.Error("Middle Record (1995) missing from 1990-2000")
|
||||||
|
}
|
||||||
|
for _, absent := range []string{"Old Record", "New Record", "Undated Record"} {
|
||||||
|
if got[absent] {
|
||||||
|
t.Errorf("%s present in 1990-2000 range", absent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open upper edge.
|
||||||
|
got = titles("year_from=1990&limit=200")
|
||||||
|
if !got["Middle Record"] || !got["New Record"] {
|
||||||
|
t.Error("open-ended year_from should include 1995 and 2020")
|
||||||
|
}
|
||||||
|
if got["Old Record"] {
|
||||||
|
t.Error("Old Record (1972) present in year_from=1990")
|
||||||
|
}
|
||||||
|
if got["Undated Record"] {
|
||||||
|
t.Error("undated album present in an open-ended range")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListLibraryAlbums_RejectsGenreAndYearTogether(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet,
|
||||||
|
"/api/library/albums?genre=Rock&year_from=1990", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleListLibraryAlbums(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400 for combined filters", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListAlbumYears_ExcludesUndatedAlbums(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
artist := seedArtist(t, pool, "Years Only")
|
||||||
|
seedAlbum(t, pool, artist.ID, "Dated One", 1984)
|
||||||
|
seedAlbum(t, pool, artist.ID, "No Date", 0)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/library/years", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleListAlbumYears(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var got []yearCount
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
found1984 := false
|
||||||
|
for _, y := range got {
|
||||||
|
if y.Year == 1984 {
|
||||||
|
found1984 = true
|
||||||
|
}
|
||||||
|
if y.Year == 0 {
|
||||||
|
t.Error("year 0 present — undated albums leaked into the index")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found1984 {
|
||||||
|
t.Error("1984 missing from the year index")
|
||||||
|
}
|
||||||
|
// Newest-first ordering, so a picker reads chronologically without the
|
||||||
|
// client re-sorting.
|
||||||
|
for i := 1; i < len(got); i++ {
|
||||||
|
if got[i-1].Year < got[i].Year {
|
||||||
|
t.Errorf("years not descending at %d: %d then %d", i, got[i-1].Year, got[i].Year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf(m map[string]bool) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
30*time.Minute, 0.5, 30000)
|
||||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil, h.netSettings)
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
@@ -475,6 +475,9 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
"/api/tracks/00000000-0000-0000-0000-000000000001",
|
"/api/tracks/00000000-0000-0000-0000-000000000001",
|
||||||
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
|
"/api/tracks/00000000-0000-0000-0000-000000000001/stream",
|
||||||
"/api/search?q=x",
|
"/api/search?q=x",
|
||||||
|
// Browse indexes (#367).
|
||||||
|
"/api/library/genres",
|
||||||
|
"/api/library/years",
|
||||||
}
|
}
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
req := httptest.NewRequest(http.MethodGet, p, nil)
|
req := httptest.NewRequest(http.MethodGet, p, nil)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
@@ -11,28 +13,142 @@ import (
|
|||||||
const (
|
const (
|
||||||
recMetricsDefaultDays = 30
|
recMetricsDefaultDays = 30
|
||||||
recMetricsMaxDays = 365
|
recMetricsMaxDays = 365
|
||||||
|
|
||||||
|
// recMetricsLowVolume marks a family as low-confidence rather than
|
||||||
|
// hiding it: with fewer plays than this a skip rate is anecdote, not
|
||||||
|
// signal, but silently dropping the row would misread as "surface
|
||||||
|
// unused". The web renders low-confidence rows dimmed.
|
||||||
|
recMetricsLowVolume = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// recommendationMetric is one recommendation surface's outcomes.
|
// Surface intents (milestone #127): each family is judged against its
|
||||||
type recommendationMetric struct {
|
// job, not one global bar — discovery mixes are EXPECTED to run higher
|
||||||
Source string `json:"source"` // 'for_you' | 'discover' | mixes
|
// skip rates than the go-to surfaces.
|
||||||
Plays int64 `json:"plays"` // plays launched from this surface
|
const (
|
||||||
|
intentGoTo = "go_to"
|
||||||
|
intentDiscovery = "discovery"
|
||||||
|
intentDirect = "direct"
|
||||||
|
)
|
||||||
|
|
||||||
|
// surfaceMetric is one bucketed surface family's outcomes.
|
||||||
|
type surfaceMetric struct {
|
||||||
|
Key string `json:"key"` // stable family key ("for_you", "radio", …)
|
||||||
|
Label string `json:"label"` // display label
|
||||||
|
Plays int64 `json:"plays"` // plays launched from this family
|
||||||
Skips int64 `json:"skips"` // of those, marked skipped
|
Skips int64 `json:"skips"` // of those, marked skipped
|
||||||
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||||
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||||
|
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
||||||
|
// Breakdown splits the family into the pick-kind populations its
|
||||||
|
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
|
||||||
|
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
||||||
|
// that predate attribution. Present only when the family has at
|
||||||
|
// least one attributed play; the parent row remains the sum of its
|
||||||
|
// breakdown.
|
||||||
|
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// surfaceGroup is one intent band of surface families.
|
||||||
|
type surfaceGroup struct {
|
||||||
|
Intent string `json:"intent"` // go_to | discovery | direct
|
||||||
|
Label string `json:"label"`
|
||||||
|
Surfaces []surfaceMetric `json:"surfaces"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type recommendationMetricsResp struct {
|
type recommendationMetricsResp struct {
|
||||||
WindowDays int `json:"window_days"`
|
WindowDays int `json:"window_days"`
|
||||||
Sources []recommendationMetric `json:"sources"`
|
// Baseline is the control group: plays the user picked manually
|
||||||
|
// (source IS NULL). Surfaces are judged as deltas against it; nil
|
||||||
|
// when the window holds no manual plays.
|
||||||
|
Baseline *surfaceMetric `json:"baseline"`
|
||||||
|
Groups []surfaceGroup `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// recFamily is the bucketing target for a raw play_events.source value.
|
||||||
|
type recFamily struct {
|
||||||
|
key string
|
||||||
|
label string
|
||||||
|
intent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketRecSource maps a raw client-stamped source string to its stable
|
||||||
|
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
|
||||||
|
// their family so the table stays readable at any library size.
|
||||||
|
func bucketRecSource(src string) recFamily {
|
||||||
|
switch {
|
||||||
|
case src == "for_you":
|
||||||
|
return recFamily{"for_you", "For You", intentGoTo}
|
||||||
|
case src == "songs_like_artist":
|
||||||
|
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
|
||||||
|
case src == "radio" || strings.HasPrefix(src, "radio:"):
|
||||||
|
return recFamily{"radio", "Radio", intentGoTo}
|
||||||
|
case src == "discover":
|
||||||
|
return recFamily{"discover", "Discover", intentDiscovery}
|
||||||
|
case src == "deep_cuts":
|
||||||
|
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
|
||||||
|
case src == "rediscover":
|
||||||
|
return recFamily{"rediscover", "Rediscover", intentDiscovery}
|
||||||
|
case src == "new_for_you":
|
||||||
|
return recFamily{"new_for_you", "New for you", intentDiscovery}
|
||||||
|
case src == "on_this_day":
|
||||||
|
return recFamily{"on_this_day", "On this day", intentDiscovery}
|
||||||
|
case src == "first_listens":
|
||||||
|
return recFamily{"first_listens", "First listens", intentDiscovery}
|
||||||
|
case strings.HasPrefix(src, "album:"):
|
||||||
|
return recFamily{"direct_album", "Album plays", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "artist:"):
|
||||||
|
return recFamily{"direct_artist", "Artist plays", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "offline:"):
|
||||||
|
return recFamily{"offline", "Offline pools", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "home:"):
|
||||||
|
return recFamily{"home", "Home sections", intentDirect}
|
||||||
|
case src == "history":
|
||||||
|
return recFamily{"history", "History", intentDirect}
|
||||||
|
default:
|
||||||
|
return recFamily{"other", "Other", intentDirect}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// familyAccum merges raw source rows into one family, carrying the
|
||||||
|
// completion sample count so the merged average stays play-weighted.
|
||||||
|
type familyAccum struct {
|
||||||
|
fam recFamily
|
||||||
|
plays int64
|
||||||
|
skips int64
|
||||||
|
completionN int64
|
||||||
|
// completionSum is avg*count re-expanded, so merging N raw rows
|
||||||
|
// reduces to a single weighted division at the end.
|
||||||
|
completionSum float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
||||||
|
a.plays += row.Plays
|
||||||
|
a.skips += row.Skips
|
||||||
|
a.completionN += row.CompletionN
|
||||||
|
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *familyAccum) metric() surfaceMetric {
|
||||||
|
m := surfaceMetric{
|
||||||
|
Key: a.fam.key,
|
||||||
|
Label: a.fam.label,
|
||||||
|
Plays: a.plays,
|
||||||
|
Skips: a.skips,
|
||||||
|
LowConfidence: a.plays < recMetricsLowVolume,
|
||||||
|
}
|
||||||
|
if a.plays > 0 {
|
||||||
|
m.SkipRate = float64(a.skips) / float64(a.plays)
|
||||||
|
}
|
||||||
|
if a.completionN > 0 {
|
||||||
|
m.AvgCompletion = a.completionSum / float64(a.completionN)
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||||
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
|
// Bucketed per-surface-family outcomes for the caller over the last `days`
|
||||||
// caller over the last `days` (default 30, capped at 365), so the operator can
|
// (default 30, capped at 365), grouped by surface intent and anchored by the
|
||||||
// see which recommendation surfaces are landing and tune the taste weights.
|
// manual-plays baseline so the numbers are judgeable, not just observable.
|
||||||
// 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) {
|
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
caller, ok := requireUser(w, r)
|
caller, ok := requireUser(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -51,28 +167,142 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out := recommendationMetricsResp{
|
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
||||||
WindowDays: days,
|
}
|
||||||
Sources: make([]recommendationMetric, 0, len(rows)),
|
|
||||||
|
// pickKindLabels is the display vocabulary for play_events.pick_kind
|
||||||
|
// values (mirrors the CHECK in migration 0041). Every system mix that
|
||||||
|
// stamps provenance gets its breakdown from this one map — adding a
|
||||||
|
// stamping mix needs no metrics change.
|
||||||
|
var pickKindLabels = map[string]string{
|
||||||
|
"taste": "Taste picks",
|
||||||
|
"fresh": "Fresh picks",
|
||||||
|
"dormant": "Dormant artists",
|
||||||
|
"taste_unheard": "Taste-matched",
|
||||||
|
"cross_user": "Liked by others",
|
||||||
|
"random": "Random unheard",
|
||||||
|
"tier1": "Tier 1 (exact)",
|
||||||
|
"tier2": "Tier 2 (relaxed)",
|
||||||
|
"tier3": "Tier 3 (stretched)",
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
|
||||||
|
// pick_kind — plays recorded before the mix stamped provenance, or
|
||||||
|
// whose track had rotated out of the snapshot at ingestion) renders
|
||||||
|
// last, kept visible so the parent row's sums stay transparent instead
|
||||||
|
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
|
||||||
|
// vocabulary, so iterating the list is exhaustive.
|
||||||
|
var pickKindOrder = []string{
|
||||||
|
"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
|
||||||
|
"tier1", "tier2", "tier3", "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindFamily derives the sub-family for one (family, pick_kind)
|
||||||
|
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
|
||||||
|
func pickKindFamily(parent recFamily, kind string) recFamily {
|
||||||
|
if kind == "" {
|
||||||
|
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
|
||||||
}
|
}
|
||||||
|
label, ok := pickKindLabels[kind]
|
||||||
|
if !ok {
|
||||||
|
label = kind
|
||||||
|
}
|
||||||
|
return recFamily{parent.key + "_" + kind, label, parent.intent}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindBreakdown folds a family's per-pick-kind accums into its
|
||||||
|
// Breakdown rows. Attached only when at least one attributed play
|
||||||
|
// exists — an all-unattributed breakdown would just repeat the parent
|
||||||
|
// row, and families that never stamp (radio, direct plays) stay flat.
|
||||||
|
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
||||||
|
attributed := int64(0)
|
||||||
|
for kind, acc := range picks {
|
||||||
|
if kind != "" {
|
||||||
|
attributed += acc.plays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attributed == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]surfaceMetric, 0, len(picks))
|
||||||
|
for _, kind := range pickKindOrder {
|
||||||
|
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
||||||
|
out = append(out, acc.metric())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketMetricsResponse folds the raw per-source rows into the grouped,
|
||||||
|
// baseline-anchored response shape. Split from the handler for pure-unit
|
||||||
|
// testability.
|
||||||
|
func bucketMetricsResponse(
|
||||||
|
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
|
||||||
|
) recommendationMetricsResp {
|
||||||
|
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
|
||||||
|
families := map[string]*familyAccum{}
|
||||||
|
picks := map[string]map[string]*familyAccum{}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
source := ""
|
if row.Source == nil || *row.Source == "" {
|
||||||
if row.Source != nil {
|
baseline.add(row)
|
||||||
source = *row.Source
|
continue
|
||||||
}
|
}
|
||||||
var skipRate float64
|
fam := bucketRecSource(*row.Source)
|
||||||
if row.Plays > 0 {
|
acc, exists := families[fam.key]
|
||||||
skipRate = float64(row.Skips) / float64(row.Plays)
|
if !exists {
|
||||||
|
acc = &familyAccum{fam: fam}
|
||||||
|
families[fam.key] = acc
|
||||||
}
|
}
|
||||||
out.Sources = append(out.Sources, recommendationMetric{
|
acc.add(row)
|
||||||
Source: source,
|
// Accumulate the pick-kind population unconditionally; families
|
||||||
Plays: row.Plays,
|
// that never stamp end up all-unattributed and get no breakdown.
|
||||||
Skips: row.Skips,
|
kind := ""
|
||||||
SkipRate: skipRate,
|
if row.PickKind != nil {
|
||||||
AvgCompletion: row.AvgCompletion,
|
kind = *row.PickKind
|
||||||
})
|
}
|
||||||
|
byKind, ok := picks[fam.key]
|
||||||
|
if !ok {
|
||||||
|
byKind = map[string]*familyAccum{}
|
||||||
|
picks[fam.key] = byKind
|
||||||
|
}
|
||||||
|
pick, ok := byKind[kind]
|
||||||
|
if !ok {
|
||||||
|
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
|
||||||
|
byKind[kind] = pick
|
||||||
|
}
|
||||||
|
pick.add(row)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
|
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
|
||||||
|
if baseline.plays > 0 {
|
||||||
|
m := baseline.metric()
|
||||||
|
resp.Baseline = &m
|
||||||
|
}
|
||||||
|
for _, g := range []struct{ intent, label string }{
|
||||||
|
{intentGoTo, "Go-to surfaces"},
|
||||||
|
{intentDiscovery, "Discovery mixes"},
|
||||||
|
{intentDirect, "Direct plays"},
|
||||||
|
} {
|
||||||
|
group := surfaceGroup{Intent: g.intent, Label: g.label}
|
||||||
|
for _, acc := range families {
|
||||||
|
if acc.fam.intent == g.intent {
|
||||||
|
m := acc.metric()
|
||||||
|
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
|
||||||
|
group.Surfaces = append(group.Surfaces, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(group.Surfaces) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sort.Slice(group.Surfaces, func(i, j int) bool {
|
||||||
|
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
|
||||||
|
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
|
||||||
|
}
|
||||||
|
return group.Surfaces[i].Key < group.Surfaces[j].Key
|
||||||
|
})
|
||||||
|
resp.Groups = append(resp.Groups, group)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newMetricsRouter(h *handlers) chi.Router {
|
func newMetricsRouter(h *handlers) chi.Router {
|
||||||
@@ -20,17 +22,19 @@ func newMetricsRouter(h *handlers) chi.Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
||||||
// skip flag. A nil source inserts NULL (library/radio play).
|
// skip flag. A nil source inserts NULL (manual library play → baseline).
|
||||||
|
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
|
||||||
|
// except attributed for_you plays.
|
||||||
func seedSourcedPlay(
|
func seedSourcedPlay(
|
||||||
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
||||||
source *string, completion float64, skipped bool,
|
source, pickKind *string, completion float64, skipped bool,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := h.pool.Exec(context.Background(),
|
if _, err := h.pool.Exec(context.Background(),
|
||||||
`INSERT INTO play_events
|
`INSERT INTO play_events
|
||||||
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
|
(user_id, track_id, session_id, started_at, source, pick_kind, completion_ratio, was_skipped)
|
||||||
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
|
VALUES ($1, $2, $3, now(), $4, $5, $6, $7)`,
|
||||||
userID, trackID, sessionID, source, completion, skipped); err != nil {
|
userID, trackID, sessionID, source, pickKind, completion, skipped); err != nil {
|
||||||
t.Fatalf("seed sourced play: %v", err)
|
t.Fatalf("seed sourced play: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,7 +49,168 @@ func TestRecommendationMetrics_NoSession401(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
// findSurface returns the named family from any group, or nil.
|
||||||
|
func findSurface(resp recommendationMetricsResp, key string) *surfaceMetric {
|
||||||
|
for _, g := range resp.Groups {
|
||||||
|
for i := range g.Surfaces {
|
||||||
|
if g.Surfaces[i].Key == key {
|
||||||
|
return &g.Surfaces[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("for_you"), Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 2.0 / 3},
|
||||||
|
// Two radio sessions collapse into one "radio" family; weighted
|
||||||
|
// completion = (0.2*1 + 0.8*1) / 2 = 0.5.
|
||||||
|
{Source: src("radio:aaaa"), Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2},
|
||||||
|
{Source: src("radio:bbbb"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
|
||||||
|
{Source: src("album:cccc"), Plays: 1, Skips: 0, CompletionN: 0, AvgCompletion: 0},
|
||||||
|
{Source: src("discover"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
|
||||||
|
// NULL source = manual plays → baseline, not a group row.
|
||||||
|
{Source: nil, Plays: 25, Skips: 5, CompletionN: 20, AvgCompletion: 0.9},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
if resp.Baseline == nil {
|
||||||
|
t.Fatal("baseline missing")
|
||||||
|
}
|
||||||
|
if resp.Baseline.Plays != 25 || resp.Baseline.SkipRate != 0.2 {
|
||||||
|
t.Errorf("baseline = %+v, want plays=25 skip_rate=0.2", resp.Baseline)
|
||||||
|
}
|
||||||
|
if resp.Baseline.LowConfidence {
|
||||||
|
t.Error("baseline with 25 plays should not be low-confidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
radio := findSurface(resp, "radio")
|
||||||
|
if radio == nil {
|
||||||
|
t.Fatal("radio family missing")
|
||||||
|
}
|
||||||
|
if radio.Plays != 2 || radio.Skips != 1 {
|
||||||
|
t.Errorf("radio plays/skips = %d/%d, want 2/1", radio.Plays, radio.Skips)
|
||||||
|
}
|
||||||
|
if radio.AvgCompletion < 0.49 || radio.AvgCompletion > 0.51 {
|
||||||
|
t.Errorf("radio avg_completion = %.3f, want 0.5 (play-weighted merge)", radio.AvgCompletion)
|
||||||
|
}
|
||||||
|
if !radio.LowConfidence {
|
||||||
|
t.Error("radio with 2 plays should be low-confidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := findSurface(resp, "direct_album"); s == nil || s.Plays != 1 {
|
||||||
|
t.Errorf("direct_album = %+v, want plays=1", s)
|
||||||
|
}
|
||||||
|
if s := findSurface(resp, ""); s != nil {
|
||||||
|
t.Error("NULL source must not appear as a surface family")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No attributed (taste/fresh) plays in this fixture → no breakdown;
|
||||||
|
// an all-unattributed breakdown would just repeat the parent row.
|
||||||
|
if fy := findSurface(resp, "for_you"); fy == nil || fy.Breakdown != nil {
|
||||||
|
t.Errorf("for_you breakdown = %+v, want nil without attributed plays", fy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group ordering is intent-banded: go_to before discovery before direct.
|
||||||
|
wantOrder := []string{intentGoTo, intentDiscovery, intentDirect}
|
||||||
|
if len(resp.Groups) != len(wantOrder) {
|
||||||
|
t.Fatalf("groups = %d, want %d", len(resp.Groups), len(wantOrder))
|
||||||
|
}
|
||||||
|
for i, g := range resp.Groups {
|
||||||
|
if g.Intent != wantOrder[i] {
|
||||||
|
t.Errorf("group[%d].intent = %s, want %s", i, g.Intent, wantOrder[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
kind := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("for_you"), PickKind: kind("taste"), Plays: 30, Skips: 3,
|
||||||
|
CompletionN: 30, AvgCompletion: 0.9},
|
||||||
|
{Source: src("for_you"), PickKind: kind("fresh"), Plays: 10, Skips: 4,
|
||||||
|
CompletionN: 10, AvgCompletion: 0.5},
|
||||||
|
// NULL pick_kind = plays that predate attribution.
|
||||||
|
{Source: src("for_you"), Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.7},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
fy := findSurface(resp, "for_you")
|
||||||
|
if fy == nil {
|
||||||
|
t.Fatal("for_you family missing")
|
||||||
|
}
|
||||||
|
// The parent row stays the sum of its breakdown.
|
||||||
|
if fy.Plays != 45 || fy.Skips != 8 {
|
||||||
|
t.Errorf("for_you plays/skips = %d/%d, want 45/8", fy.Plays, fy.Skips)
|
||||||
|
}
|
||||||
|
if len(fy.Breakdown) != 3 {
|
||||||
|
t.Fatalf("breakdown rows = %d, want 3 (taste, fresh, earlier)", len(fy.Breakdown))
|
||||||
|
}
|
||||||
|
wantKeys := []string{"for_you_taste", "for_you_fresh", "for_you_unattributed"}
|
||||||
|
for i, k := range wantKeys {
|
||||||
|
if fy.Breakdown[i].Key != k {
|
||||||
|
t.Errorf("breakdown[%d].key = %s, want %s", i, fy.Breakdown[i].Key, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taste, fresh := fy.Breakdown[0], fy.Breakdown[1]
|
||||||
|
if taste.Plays != 30 || taste.SkipRate != 0.1 || taste.LowConfidence {
|
||||||
|
t.Errorf("taste = %+v, want plays=30 skip_rate=0.1 confident", taste)
|
||||||
|
}
|
||||||
|
if fresh.Plays != 10 || fresh.SkipRate != 0.4 || !fresh.LowConfidence {
|
||||||
|
t.Errorf("fresh = %+v, want plays=10 skip_rate=0.4 low-confidence", fresh)
|
||||||
|
}
|
||||||
|
// Breakdown rows never appear as their own surface families.
|
||||||
|
if s := findSurface(resp, "for_you_taste"); s != nil {
|
||||||
|
t.Error("for_you_taste must not be a top-level surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
|
||||||
|
// Provenance is standard (#1270): Discover's bucket stamps surface as
|
||||||
|
// a breakdown exactly like For You's taste/fresh — this is what makes
|
||||||
|
// the bucket allocation judgeable instead of a guess.
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
kind := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("discover"), PickKind: kind("dormant"), Plays: 8, Skips: 2,
|
||||||
|
CompletionN: 8, AvgCompletion: 0.8},
|
||||||
|
{Source: src("discover"), PickKind: kind("cross_user"), Plays: 6, Skips: 3,
|
||||||
|
CompletionN: 6, AvgCompletion: 0.6},
|
||||||
|
{Source: src("discover"), PickKind: kind("random"), Plays: 4, Skips: 3,
|
||||||
|
CompletionN: 4, AvgCompletion: 0.4},
|
||||||
|
// NULL pick_kind = plays that predate bucket stamping.
|
||||||
|
{Source: src("discover"), Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
d := findSurface(resp, "discover")
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("discover family missing")
|
||||||
|
}
|
||||||
|
if d.Plays != 20 || d.Skips != 8 {
|
||||||
|
t.Errorf("discover plays/skips = %d/%d, want 20/8", d.Plays, d.Skips)
|
||||||
|
}
|
||||||
|
wantKeys := []string{
|
||||||
|
"discover_dormant", "discover_cross_user", "discover_random",
|
||||||
|
"discover_unattributed",
|
||||||
|
}
|
||||||
|
if len(d.Breakdown) != len(wantKeys) {
|
||||||
|
t.Fatalf("breakdown rows = %d, want %d", len(d.Breakdown), len(wantKeys))
|
||||||
|
}
|
||||||
|
for i, k := range wantKeys {
|
||||||
|
if d.Breakdown[i].Key != k {
|
||||||
|
t.Errorf("breakdown[%d].key = %s, want %s", i, d.Breakdown[i].Key, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b := d.Breakdown[0]; b.Label != "Dormant artists" || b.Plays != 8 {
|
||||||
|
t.Errorf("dormant row = %+v, want label=Dormant artists plays=8", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
}
|
}
|
||||||
@@ -57,15 +222,17 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
|||||||
session := seedPlaySession(t, pool, user.ID, time.Now())
|
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||||
|
|
||||||
forYou := "for_you"
|
forYou := "for_you"
|
||||||
discover := "discover"
|
radioA := "radio:11111111-1111-1111-1111-111111111111"
|
||||||
|
taste, fresh := "taste", "fresh"
|
||||||
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
// 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)
|
// Two attributed as taste picks, the skipped one as a fresh pick (#1249).
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false)
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false)
|
||||||
// discover: 1 play.
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true)
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
|
// A radio session play collapses into the "radio" family.
|
||||||
// library play (NULL source) — must be excluded.
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, nil, 0.8, false)
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
|
// Manual play (NULL source) — the baseline row.
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, nil, 1.0, false)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||||
req = withUser(req, user)
|
req = withUser(req, user)
|
||||||
@@ -82,15 +249,11 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
|||||||
if resp.WindowDays != recMetricsDefaultDays {
|
if resp.WindowDays != recMetricsDefaultDays {
|
||||||
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
||||||
}
|
}
|
||||||
bySource := map[string]recommendationMetric{}
|
if resp.Baseline == nil || resp.Baseline.Plays != 1 {
|
||||||
for _, m := range resp.Sources {
|
t.Fatalf("baseline = %+v, want plays=1", resp.Baseline)
|
||||||
bySource[m.Source] = m
|
|
||||||
}
|
}
|
||||||
if _, present := bySource[""]; present {
|
fy := findSurface(resp, "for_you")
|
||||||
t.Error("NULL-source (library) plays should be excluded")
|
if fy == nil {
|
||||||
}
|
|
||||||
fy, ok := bySource["for_you"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("for_you metrics missing")
|
t.Fatal("for_you metrics missing")
|
||||||
}
|
}
|
||||||
if fy.Plays != 3 || fy.Skips != 1 {
|
if fy.Plays != 3 || fy.Skips != 1 {
|
||||||
@@ -102,7 +265,17 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
|||||||
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
||||||
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
||||||
}
|
}
|
||||||
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
|
// Pick-kind attribution surfaces as the For You breakdown (#1249).
|
||||||
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
|
if len(fy.Breakdown) != 2 {
|
||||||
|
t.Fatalf("for_you breakdown rows = %d, want 2 (taste, fresh)", len(fy.Breakdown))
|
||||||
|
}
|
||||||
|
if b := fy.Breakdown[0]; b.Key != "for_you_taste" || b.Plays != 2 || b.Skips != 0 {
|
||||||
|
t.Errorf("breakdown[0] = %+v, want for_you_taste plays=2 skips=0", b)
|
||||||
|
}
|
||||||
|
if b := fy.Breakdown[1]; b.Key != "for_you_fresh" || b.Plays != 1 || b.Skips != 1 {
|
||||||
|
t.Errorf("breakdown[1] = %+v, want for_you_fresh plays=1 skips=1", b)
|
||||||
|
}
|
||||||
|
if radio := findSurface(resp, "radio"); radio == nil || radio.Plays != 1 {
|
||||||
|
t.Errorf("radio family = %+v, want plays=1 (collapsed from radio:<uuid>)", radio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/audit"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errNoCurrentSession means the request authenticated but the middleware
|
||||||
|
// didn't record which session did it — which should be impossible on a route
|
||||||
|
// behind RequireUser. It matters because "log out everywhere else" is defined
|
||||||
|
// by exclusion: without knowing which session is ours, the safe-looking
|
||||||
|
// action would sign the caller out too.
|
||||||
|
var errNoCurrentSession = errors.New("no session id in request context")
|
||||||
|
|
||||||
|
// sessionResp is one row of the active-sessions list.
|
||||||
|
//
|
||||||
|
// token_hash is absent, and that is the point of storing only a hash: it
|
||||||
|
// never leaves the database, so this surface can list sessions without
|
||||||
|
// handing out anything that could be replayed.
|
||||||
|
type sessionResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
// CreatedIP is frozen at issue time; LastIP moves with the session. The
|
||||||
|
// pair is what makes a stolen token legible — same device string, but an
|
||||||
|
// address the user doesn't recognise.
|
||||||
|
CreatedIP string `json:"created_ip"`
|
||||||
|
LastIP string `json:"last_ip"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
LastSeenAt time.Time `json:"last_seen_at"`
|
||||||
|
// Current marks the session making this request so the UI can label it
|
||||||
|
// and not offer a "log out" that signs the user out of the page they're
|
||||||
|
// standing on.
|
||||||
|
Current bool `json:"current"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type revokedResp struct {
|
||||||
|
Revoked int `json:"revoked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListMySessions implements GET /api/me/sessions.
|
||||||
|
func (h *handlers) handleListMySessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Absent id is tolerated here (unlike logout-others): the list still
|
||||||
|
// renders, it just won't flag a current row.
|
||||||
|
currentID, _ := auth.SessionIDFromContext(r.Context())
|
||||||
|
|
||||||
|
rows, err := dbq.New(h.pool).ListSessionsForUser(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("list sessions: query failed", "err", err)
|
||||||
|
writeErr(w, apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]sessionResp, 0, len(rows))
|
||||||
|
for _, s := range rows {
|
||||||
|
out = append(out, sessionResp{
|
||||||
|
ID: uuidToString(s.ID),
|
||||||
|
UserAgent: s.UserAgent,
|
||||||
|
CreatedIP: s.CreatedIp,
|
||||||
|
LastIP: s.LastIp,
|
||||||
|
CreatedAt: s.CreatedAt.Time,
|
||||||
|
LastSeenAt: s.LastSeenAt.Time,
|
||||||
|
Current: s.ID == currentID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeMySession implements DELETE /api/me/sessions/{id}.
|
||||||
|
func (h *handlers) handleRevokeMySession(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if !ok {
|
||||||
|
// Malformed and belongs-to-someone-else collapse to one answer on
|
||||||
|
// purpose: a distinguishable response would let a caller probe
|
||||||
|
// whether another user's session id exists.
|
||||||
|
writeErr(w, apierror.NotFound("session"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := dbq.New(h.pool).DeleteSessionForUser(r.Context(), dbq.DeleteSessionForUserParams{
|
||||||
|
ID: id,
|
||||||
|
UserID: user.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("revoke session: delete failed", "err", err)
|
||||||
|
writeErr(w, apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
writeErr(w, apierror.NotFound("session"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevoke, nil)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRevokeMyOtherSessions implements POST /api/me/sessions/logout-others.
|
||||||
|
func (h *handlers) handleRevokeMyOtherSessions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentID, ok := auth.SessionIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
// Refuse rather than guess: deleting "all but unknown" is deleting
|
||||||
|
// all, which would log the caller out of the page they invoked this
|
||||||
|
// from and look exactly like the attack they were defending against.
|
||||||
|
h.logger.Error("revoke other sessions: no session id in context")
|
||||||
|
writeErr(w, apierror.Internal(errNoCurrentSession))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := dbq.New(h.pool).DeleteOtherSessionsForUser(r.Context(), dbq.DeleteOtherSessionsForUserParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
ID: currentID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("revoke other sessions: delete failed", "err", err)
|
||||||
|
writeErr(w, apierror.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audit.WriteOrLog(r.Context(), h.pool, h.logger, user.ID, user.ID, audit.ActionSessionRevokeOthers, nil)
|
||||||
|
writeJSON(w, http.StatusOK, revokedResp{Revoked: int(n)})
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedSession inserts a session for userID and returns its id.
|
||||||
|
func seedSession(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, ip string) pgtype.UUID {
|
||||||
|
t.Helper()
|
||||||
|
token, err := auth.MintSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mint: %v", err)
|
||||||
|
}
|
||||||
|
sess, err := dbq.New(pool).InsertSession(context.Background(), dbq.InsertSessionParams{
|
||||||
|
UserID: userID,
|
||||||
|
TokenHash: auth.HashSessionToken(token),
|
||||||
|
UserAgent: "test-agent",
|
||||||
|
Ip: ip,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert session: %v", err)
|
||||||
|
}
|
||||||
|
return sess.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSession attaches the user and current-session id the handlers expect
|
||||||
|
// from RequireUser.
|
||||||
|
func withSession(r *http.Request, user dbq.User, sessionID pgtype.UUID) *http.Request {
|
||||||
|
ctx := context.WithValue(r.Context(), userCtxKeyForTest(), user)
|
||||||
|
ctx = context.WithValue(ctx, auth.SessionIDCtxKeyForTest(), sessionID)
|
||||||
|
return r.WithContext(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// withURLParam wires a chi route param, which handlers read via chi.URLParam.
|
||||||
|
func withURLParam(r *http.Request, key, value string) *http.Request {
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add(key, value)
|
||||||
|
return r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rule #47 assertion. A delete keyed only on session id would let any
|
||||||
|
// household member revoke any other member's session by id — this pins that
|
||||||
|
// the user scope is actually in the WHERE clause and not just intended.
|
||||||
|
func TestRevokeMySession_CannotRevokeAnotherUsersSession(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
alice := seedUser(t, pool, "alice", "hunter2", false)
|
||||||
|
bob := seedUser(t, pool, "bob", "hunter2", false)
|
||||||
|
|
||||||
|
bobSession := seedSession(t, pool, bob.ID, "203.0.113.9")
|
||||||
|
aliceSession := seedSession(t, pool, alice.ID, "203.0.113.1")
|
||||||
|
|
||||||
|
target := uuidToString(bobSession)
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil)
|
||||||
|
req = withURLParam(req, "id", target)
|
||||||
|
req = withSession(req, alice, aliceSession)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleRevokeMySession(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404 (not another user's to revoke)", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 404 must mean "didn't happen", not merely "wasn't reported".
|
||||||
|
var stillThere bool
|
||||||
|
if err := pool.QueryRow(context.Background(),
|
||||||
|
`SELECT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, bobSession,
|
||||||
|
).Scan(&stillThere); err != nil {
|
||||||
|
t.Fatalf("exists check: %v", err)
|
||||||
|
}
|
||||||
|
if !stillThere {
|
||||||
|
t.Error("bob's session was deleted by alice's request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRevokeMySession_DeletesOwnSession(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
alice := seedUser(t, pool, "alice", "hunter2", false)
|
||||||
|
current := seedSession(t, pool, alice.ID, "203.0.113.1")
|
||||||
|
other := seedSession(t, pool, alice.ID, "198.51.100.7")
|
||||||
|
|
||||||
|
target := uuidToString(other)
|
||||||
|
req := httptest.NewRequest(http.MethodDelete, "/api/me/sessions/"+target, nil)
|
||||||
|
req = withURLParam(req, "id", target)
|
||||||
|
req = withSession(req, alice, current)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleRevokeMySession(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want 204", w.Code)
|
||||||
|
}
|
||||||
|
var gone bool
|
||||||
|
if err := pool.QueryRow(context.Background(),
|
||||||
|
`SELECT NOT EXISTS (SELECT 1 FROM sessions WHERE id = $1)`, other,
|
||||||
|
).Scan(&gone); err != nil {
|
||||||
|
t.Fatalf("exists check: %v", err)
|
||||||
|
}
|
||||||
|
if !gone {
|
||||||
|
t.Error("session survived its own owner's revoke")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Log out everywhere else" must spare the caller — otherwise the button
|
||||||
|
// signs you out of the page you pressed it on, which is indistinguishable
|
||||||
|
// from the compromise it's meant to remedy.
|
||||||
|
func TestRevokeMyOtherSessions_SparesCurrentAndOtherUsers(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
alice := seedUser(t, pool, "alice", "hunter2", false)
|
||||||
|
bob := seedUser(t, pool, "bob", "hunter2", false)
|
||||||
|
|
||||||
|
current := seedSession(t, pool, alice.ID, "203.0.113.1")
|
||||||
|
seedSession(t, pool, alice.ID, "198.51.100.7")
|
||||||
|
seedSession(t, pool, alice.ID, "198.51.100.8")
|
||||||
|
bobSession := seedSession(t, pool, bob.ID, "203.0.113.9")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil)
|
||||||
|
req = withSession(req, alice, current)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleRevokeMyOtherSessions(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var body revokedResp
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&body); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if body.Revoked != 2 {
|
||||||
|
t.Errorf("revoked = %d, want 2 (alice's other two, not bob's)", body.Revoked)
|
||||||
|
}
|
||||||
|
|
||||||
|
var aliceRemaining, bobRemaining int
|
||||||
|
if err := pool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID,
|
||||||
|
).Scan(&aliceRemaining); err != nil {
|
||||||
|
t.Fatalf("count alice: %v", err)
|
||||||
|
}
|
||||||
|
if aliceRemaining != 1 {
|
||||||
|
t.Errorf("alice sessions = %d, want 1 (the current one)", aliceRemaining)
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM sessions WHERE id = $1`, bobSession,
|
||||||
|
).Scan(&bobRemaining); err != nil {
|
||||||
|
t.Fatalf("count bob: %v", err)
|
||||||
|
}
|
||||||
|
if bobRemaining != 1 {
|
||||||
|
t.Error("bob's session was caught in alice's logout-others")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a current-session id the exclusion has nothing to exclude, so the
|
||||||
|
// handler must refuse rather than delete everything.
|
||||||
|
func TestRevokeMyOtherSessions_RefusesWithoutCurrentSession(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
alice := seedUser(t, pool, "alice", "hunter2", false)
|
||||||
|
seedSession(t, pool, alice.ID, "203.0.113.1")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/me/sessions/logout-others", nil)
|
||||||
|
req = req.WithContext(context.WithValue(req.Context(), userCtxKeyForTest(), alice))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleRevokeMyOtherSessions(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Errorf("status = %d, want 500", w.Code)
|
||||||
|
}
|
||||||
|
var remaining int
|
||||||
|
if err := pool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM sessions WHERE user_id = $1`, alice.ID,
|
||||||
|
).Scan(&remaining); err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if remaining != 1 {
|
||||||
|
t.Errorf("sessions = %d, want 1 — refusing must not delete", remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListMySessions_FlagsCurrentAndScopesToUser(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
alice := seedUser(t, pool, "alice", "hunter2", false)
|
||||||
|
bob := seedUser(t, pool, "bob", "hunter2", false)
|
||||||
|
|
||||||
|
current := seedSession(t, pool, alice.ID, "203.0.113.1")
|
||||||
|
seedSession(t, pool, alice.ID, "198.51.100.7")
|
||||||
|
seedSession(t, pool, bob.ID, "203.0.113.9")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/me/sessions", nil)
|
||||||
|
req = withSession(req, alice, current)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleListMySessions(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
var got []sessionResp
|
||||||
|
if err := json.NewDecoder(w.Body).Decode(&got); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("sessions = %d, want 2 (bob's must not appear)", len(got))
|
||||||
|
}
|
||||||
|
currentCount := 0
|
||||||
|
for _, s := range got {
|
||||||
|
if s.Current {
|
||||||
|
currentCount++
|
||||||
|
if s.ID != uuidToString(current) {
|
||||||
|
t.Errorf("current flagged on %s, want %s", s.ID, uuidToString(current))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.CreatedIP == "" {
|
||||||
|
t.Error("created_ip empty — the whole point of the surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if currentCount != 1 {
|
||||||
|
t.Errorf("current-flagged rows = %d, want exactly 1", currentCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RadioResponse is the body of GET /api/radio.
|
// RadioResponse is the body of GET /api/radio.
|
||||||
@@ -80,6 +82,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
||||||
|
// Condition on the current device (#1551): the latest play's device is a
|
||||||
|
// cheap, request-free proxy for what the user is on right now.
|
||||||
|
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
|
||||||
|
|
||||||
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
||||||
limits := recommendation.DefaultCandidateSourceLimits()
|
limits := recommendation.DefaultCandidateSourceLimits()
|
||||||
@@ -100,16 +105,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
weights := recommendation.ScoringWeights{
|
// Scoring weights come from the DB-backed tuning lab (#1250) —
|
||||||
BaseWeight: h.recCfg.BaseWeight,
|
// read per request so an admin change takes effect live.
|
||||||
LikeBoost: h.recCfg.LikeBoost,
|
weights := h.recSettings.Weights(recsettings.ScopeRadio)
|
||||||
RecencyWeight: h.recCfg.RecencyWeight,
|
|
||||||
SkipPenalty: h.recCfg.SkipPenalty,
|
|
||||||
JitterMagnitude: h.recCfg.JitterMagnitude,
|
|
||||||
ContextWeight: h.recCfg.ContextWeight,
|
|
||||||
SimilarityWeight: h.recCfg.SimilarityWeight,
|
|
||||||
TasteWeight: h.recCfg.TasteWeight,
|
|
||||||
}
|
|
||||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||||
|
|
||||||
out := make([]TrackRef, 0, len(picks)+1)
|
out := make([]TrackRef, 0, len(picks)+1)
|
||||||
@@ -156,6 +154,24 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// latestDeviceClass returns the device_class of the user's most recent play as
|
||||||
|
// the "current device" for context conditioning (#1551), or "" when unknown
|
||||||
|
// (no plays yet, or the latest play predates device capture). Best-effort: a
|
||||||
|
// lookup failure yields a device-agnostic ("") affinity cell.
|
||||||
|
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
|
||||||
|
dc, err := q.GetLatestPlayDeviceClassForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
logger.Warn("api: radio: latest device class", "err", err)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if dc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *dc
|
||||||
|
}
|
||||||
|
|
||||||
// parseExcludeParam parses a comma-separated list of UUIDs from the
|
// parseExcludeParam parses a comma-separated list of UUIDs from the
|
||||||
// `exclude` query string, silently dropping malformed entries. Returns
|
// `exclude` query string, silently dropping malformed entries. Returns
|
||||||
// nil for empty or all-malformed input.
|
// nil for empty or all-malformed input.
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
)
|
)
|
||||||
@@ -19,6 +25,12 @@ type suggestionView struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
Attribution []seedContributionView `json:"attribution"`
|
Attribution []seedContributionView `json:"attribution"`
|
||||||
|
// MatchedTags are the candidate's tags that overlap the user's taste
|
||||||
|
// profile, strongest first (#2377) — the "matches: shoegaze, melancholic"
|
||||||
|
// line. Omitted when empty, which is common: tag coverage for
|
||||||
|
// out-of-library artists is permanently partial (#2376), and the card
|
||||||
|
// falls back to the seed attribution it has always shown.
|
||||||
|
MatchedTags []string `json:"matched_tags,omitempty"`
|
||||||
// ImageURL is resolved on-demand from Lidarr (out-of-library
|
// ImageURL is resolved on-demand from Lidarr (out-of-library
|
||||||
// artists have no local art row). Omitted when Lidarr is disabled
|
// artists have no local art row). Omitted when Lidarr is disabled
|
||||||
// or has no match — the client falls back to a placeholder. Not
|
// or has no match — the client falls back to a placeholder. Not
|
||||||
@@ -65,7 +77,11 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
|
|||||||
halfLife = f
|
halfLife = f
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit)
|
// Read the tuned weight per request so an admin change takes effect on the
|
||||||
|
// next refresh, no restart (rule #25).
|
||||||
|
tagWeight := h.recSettings.Discover().TagOverlapWeight
|
||||||
|
suggestions, err := recommendation.SuggestArtists(
|
||||||
|
r.Context(), h.pool, user.ID, halfLife, limit, tagWeight)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: list suggestions", "err", err)
|
h.logger.Error("api: list suggestions", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
|
writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
|
||||||
@@ -86,12 +102,154 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
out = append(out, suggestionView{
|
out = append(out, suggestionView{
|
||||||
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
|
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
|
||||||
|
MatchedTags: s.MatchedTags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
h.resolveSuggestionArt(r.Context(), out)
|
h.resolveSuggestionArt(r.Context(), out)
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxSnoozeDays caps a client-supplied duration. The DEFAULT is not here: it's
|
||||||
|
// a DB-backed knob on the admin tuning card (rule #25), read per request via
|
||||||
|
// recSettings.Discover().SnoozeDays. See #2377.
|
||||||
|
const maxSnoozeDays = 365.0
|
||||||
|
|
||||||
|
// snoozeRequest is the POST body. Both fields are optional in the JSON sense
|
||||||
|
// (an absent body snoozes for the default), but Name is required in practice:
|
||||||
|
// candidates are out-of-library, so the server has no artists row to resolve a
|
||||||
|
// display name from and the un-snooze list would have nothing to show. The
|
||||||
|
// client always has it — it just rendered the card.
|
||||||
|
type snoozeRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Days float64 `json:"days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// snoozeView is one row of GET /api/discover/snoozes.
|
||||||
|
type snoozeView struct {
|
||||||
|
MBID string `json:"mbid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SnoozedUntil pgtype.Timestamptz `json:"snoozed_until"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSnoozeSuggestion implements
|
||||||
|
// POST /api/discover/suggestions/{mbid}/snooze.
|
||||||
|
//
|
||||||
|
// Parks a candidate for `days` (default 90, capped at 365). Idempotent:
|
||||||
|
// snoozing an already-snoozed candidate extends it rather than conflicting.
|
||||||
|
//
|
||||||
|
// This is NOT negative feedback. It records no verdict on the artist and is
|
||||||
|
// never read by internal/taste — see 0049_suggestion_snoozes.up.sql for the
|
||||||
|
// rule #101 reasoning. Returns 204.
|
||||||
|
func (h *handlers) handleSnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
|
||||||
|
if mbid == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty body is a valid "snooze this for the default period", so EOF
|
||||||
|
// is not an error here — decodeBody would reject it as a malformed body.
|
||||||
|
var body snoozeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", ""))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(body.Name)
|
||||||
|
if name == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", "name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days := body.Days
|
||||||
|
if days <= 0 {
|
||||||
|
days = h.recSettings.Discover().SnoozeDays
|
||||||
|
}
|
||||||
|
if days > maxSnoozeDays {
|
||||||
|
// Clamp rather than reject: a client asking for longer than we allow
|
||||||
|
// still means "park this", and failing the write would leave the card
|
||||||
|
// sitting there as if the tap did nothing.
|
||||||
|
days = maxSnoozeDays
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
if err := q.SnoozeSuggestion(r.Context(), dbq.SnoozeSuggestionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CandidateMbid: mbid,
|
||||||
|
CandidateName: name,
|
||||||
|
Column4: days,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("api: snooze suggestion", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("failed to snooze suggestion", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnsnoozeSuggestion implements
|
||||||
|
// DELETE /api/discover/suggestions/{mbid}/snooze.
|
||||||
|
//
|
||||||
|
// Brings a parked candidate back immediately. 404s an MBID this user never
|
||||||
|
// snoozed, so the client can tell "undone" from "there was nothing there".
|
||||||
|
func (h *handlers) handleUnsnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
|
||||||
|
if mbid == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.UnsnoozeSuggestion(r.Context(), dbq.UnsnoozeSuggestionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CandidateMbid: mbid,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: unsnooze suggestion", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("failed to unsnooze suggestion", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
writeErr(w, apierror.NotFound("snooze"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListSuggestionSnoozes implements GET /api/discover/snoozes.
|
||||||
|
//
|
||||||
|
// The un-snooze surface needs this: a parked candidate is by definition
|
||||||
|
// absent from the suggestion deck, so without a list there is no way to
|
||||||
|
// reach the DELETE above. Scoped to the caller (rule #47). Expired rows are
|
||||||
|
// already filtered by the query — the hourly gc sweep only reclaims space.
|
||||||
|
func (h *handlers) handleListSuggestionSnoozes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListActiveSuggestionSnoozes(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list suggestion snoozes", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("failed to load snoozes", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]snoozeView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, snoozeView{
|
||||||
|
MBID: row.CandidateMbid,
|
||||||
|
Name: row.CandidateName,
|
||||||
|
SnoozedUntil: row.SnoozedUntil,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
|
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
|
||||||
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
|
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
|
||||||
// Lidarr is the only source — when it's disabled, unreachable, or has
|
// Lidarr is the only source — when it's disabled, unreachable, or has
|
||||||
|
|||||||
@@ -89,12 +89,19 @@ type TrackRef struct {
|
|||||||
type ArtistDetail struct {
|
type ArtistDetail struct {
|
||||||
ArtistRef
|
ArtistRef
|
||||||
Albums []AlbumRef `json:"albums"`
|
Albums []AlbumRef `json:"albums"`
|
||||||
|
// Genres carried by this artist's tracks, for quick-jump chips (#367).
|
||||||
|
// Always non-nil at JSON so the client can iterate without a null check.
|
||||||
|
Genres []string `json:"genres"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AlbumDetail is the response body of GET /api/albums/{id}.
|
// AlbumDetail is the response body of GET /api/albums/{id}.
|
||||||
type AlbumDetail struct {
|
type AlbumDetail struct {
|
||||||
AlbumRef
|
AlbumRef
|
||||||
Tracks []TrackRef `json:"tracks"`
|
Tracks []TrackRef `json:"tracks"`
|
||||||
|
// Genres carried by this album's tracks, for quick-jump chips (#367).
|
||||||
|
// Derived from the tracks rather than stored on the album, because genre
|
||||||
|
// lives on tracks and an album's tracks can disagree. Non-nil at JSON.
|
||||||
|
Genres []string `json:"genres"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchResponse is the body of GET /api/search. Each facet carries its own
|
// SearchResponse is the body of GET /api/search. Each facet carries its own
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ const (
|
|||||||
ActionTokenRegenerate Action = "token_regenerate"
|
ActionTokenRegenerate Action = "token_regenerate"
|
||||||
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
||||||
ActionPasswordResetByEmail Action = "password_reset_via_email"
|
ActionPasswordResetByEmail Action = "password_reset_via_email"
|
||||||
|
|
||||||
|
// Active-sessions surface (#370). Worth auditing rather than silent:
|
||||||
|
// revoking sessions is what a user does when they think an account is
|
||||||
|
// compromised, so the audit trail is most useful precisely when it's
|
||||||
|
// exercised.
|
||||||
|
ActionSessionRevoke Action = "session_revoke"
|
||||||
|
ActionSessionRevokeOthers Action = "session_revoke_others"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Write inserts one audit_log row. metadata is marshaled as JSON;
|
// Write inserts one audit_log row. metadata is marshaled as JSON;
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientIP returns the caller's address, reading through trustedProxyHops
|
||||||
|
// reverse proxies (#2453).
|
||||||
|
//
|
||||||
|
// X-Forwarded-For grows left-to-right: every proxy APPENDS the peer it
|
||||||
|
// received the request from. For client -> CDN -> own-proxy -> Minstrel the
|
||||||
|
// app sees XFF = [client, CDN] and RemoteAddr = own-proxy. Each trusted proxy
|
||||||
|
// therefore accounts for one entry counting from the right, and the first
|
||||||
|
// address we were NOT told to trust is the client:
|
||||||
|
//
|
||||||
|
// hops 0 -> RemoteAddr; XFF ignored entirely
|
||||||
|
// hops 1 -> XFF[1] = CDN — trusting only our own proxy, the most we can
|
||||||
|
// honestly claim is the address it told us about
|
||||||
|
// hops 2 -> XFF[0] = client
|
||||||
|
//
|
||||||
|
// This replaces an earlier heuristic that ignored XFF whenever RemoteAddr was
|
||||||
|
// public. That was safe but useless in the deployment that matters: a proxy
|
||||||
|
// on a public address (separate host, or a CDN) meant every session recorded
|
||||||
|
// the proxy, so the active-sessions surface could never show an address
|
||||||
|
// change (#370).
|
||||||
|
//
|
||||||
|
// # What the operator is asserting
|
||||||
|
//
|
||||||
|
// hops >= 1 is a DECLARATION that a proxy sits in front. Two ways to get it
|
||||||
|
// wrong, both worth understanding rather than papering over:
|
||||||
|
//
|
||||||
|
// - Set to 1+ with NO proxy: any client can forge X-Forwarded-For and pick
|
||||||
|
// what its own session row shows, defeating the compromise detection.
|
||||||
|
// - Set HIGHER than the real chain: the index runs past the proxy-written
|
||||||
|
// entries into attacker-supplied ones, same result.
|
||||||
|
//
|
||||||
|
// Both are inherent to the trusted-hop model — Rails, Caddy, Traefik and
|
||||||
|
// nginx all behave this way — which is why 0 is a first-class value and the
|
||||||
|
// admin card tells the operator to count their proxies.
|
||||||
|
func ClientIP(r *http.Request, trustedProxyHops int) string {
|
||||||
|
remote := hostOf(r.RemoteAddr)
|
||||||
|
if trustedProxyHops <= 0 {
|
||||||
|
return remote
|
||||||
|
}
|
||||||
|
chain := forwardedChain(r)
|
||||||
|
if len(chain) == 0 {
|
||||||
|
// No forwarding header: either there's genuinely no proxy, or one is
|
||||||
|
// misconfigured. The socket peer is the only thing we actually know.
|
||||||
|
return remote
|
||||||
|
}
|
||||||
|
// Clamp rather than reject: a chain shorter than the configured depth
|
||||||
|
// means the operator over-counted, and the leftmost entry is the closest
|
||||||
|
// thing to a client on offer. The caveat above covers the risk.
|
||||||
|
idx := len(chain) - trustedProxyHops
|
||||||
|
if idx < 0 {
|
||||||
|
idx = 0
|
||||||
|
}
|
||||||
|
if ip := net.ParseIP(chain[idx]); ip != nil {
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
// A proxy wrote something that isn't an address. Positional meaning is
|
||||||
|
// lost, so fall back to what we can verify ourselves.
|
||||||
|
return remote
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardedChain returns the X-Forwarded-For entries in wire order, or the
|
||||||
|
// single X-Real-IP value when XFF is absent.
|
||||||
|
//
|
||||||
|
// Entries are kept verbatim, including unparseable ones: their POSITION is
|
||||||
|
// what carries meaning here, so silently dropping a malformed hop would
|
||||||
|
// shift every index and could hand back an attacker-supplied entry.
|
||||||
|
func forwardedChain(r *http.Request) []string {
|
||||||
|
raw := r.Header.Get("X-Forwarded-For")
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
// Some proxies set only X-Real-IP, which by construction is a single
|
||||||
|
// hop — the address that proxy saw.
|
||||||
|
if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" {
|
||||||
|
return []string{real}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// hopsOf reads a trusted-depth accessor, treating a nil one as "trust
|
||||||
|
// nothing". Test contexts and any future caller that hasn't wired the
|
||||||
|
// settings service get the safe reading rather than a panic.
|
||||||
|
func hopsOf(fn func() int) int {
|
||||||
|
if fn == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostOf strips the port from a RemoteAddr, tolerating values that have none.
|
||||||
|
func hostOf(remoteAddr string) string {
|
||||||
|
host, _, err := net.SplitHostPort(remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return strings.TrimSpace(remoteAddr)
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||