Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e4727fc49 | ||
|
|
fd27819cdd | ||
|
|
37b396a7e4 | ||
|
|
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 |
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -277,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(
|
||||||
@@ -297,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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,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
|
||||||
@@ -85,30 +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.delay
|
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
|
||||||
@@ -120,16 +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 (automatic refresh). Held through the pull
|
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
|
||||||
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
|
// appears at all — only when a refresh actually changes something — and how
|
||||||
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
// long it stays, by watching the screen settle rather than by a fixed delay,
|
||||||
private const val VEIL_SETTLE_MS = 500L
|
// 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_WIPE_MS = 280
|
||||||
private const val VEIL_ALPHA = 0.96f
|
private const val VEIL_ALPHA = 0.96f
|
||||||
private const val VEIL_SPINNER_DP = 22
|
private const val VEIL_SPINNER_DP = 22
|
||||||
private const val VEIL_SPINNER_STROKE_DP = 2
|
private const val VEIL_SPINNER_STROKE_DP = 2
|
||||||
private const val VEIL_LABEL_GAP_DP = 12
|
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(
|
||||||
@@ -184,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
|
||||||
@@ -197,38 +215,13 @@ class HomeViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
private val updatingInternal = MutableStateFlow(false)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True while an automatic background refresh (the 03:00 daily rebuild
|
* Cover-art loads in flight on Home, reported by every [ServerImage]
|
||||||
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
|
* under [LocalArtSettleTracker]. The veil waits on this so artwork
|
||||||
* your mixes…" veil so the section churn — delete-then-insert in
|
* arriving a beat after its tile lands behind the veil rather than
|
||||||
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
|
* popping in on screen.
|
||||||
* hidden behind the veil instead of on screen. Manual pull-to-refresh
|
|
||||||
* and cold start are NOT veiled (they own the pull spinner / skeleton).
|
|
||||||
*/
|
*/
|
||||||
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
|
val artTracker = ArtSettleTracker()
|
||||||
|
|
||||||
init {
|
|
||||||
refresh()
|
|
||||||
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
|
||||||
// load while the server was unreachable re-pulls itself the moment
|
|
||||||
// health returns — same idiom as SyncController, one layer up.
|
|
||||||
// Veiled: content is already on screen and would otherwise churn.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect { refreshBehindVeil() }
|
|
||||||
}
|
|
||||||
// #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. Veiled so the multi-section
|
|
||||||
// rebuild churn hides behind "Updating your mixes…".
|
|
||||||
viewModelScope.launch {
|
|
||||||
eventsStream.events
|
|
||||||
.filter { it.kind == "playlist.system_rebuilt" }
|
|
||||||
.collect { refreshBehindVeil() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
||||||
@@ -241,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}")
|
||||||
}
|
}
|
||||||
@@ -278,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)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -303,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)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -328,52 +321,66 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatic background refresh with the "Updating your mixes…" veil
|
* The Error state's explicit Retry button. User-initiated, so it gets
|
||||||
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
|
* the controller's retries and reports its outcome; over an empty cache
|
||||||
* paths where Home is already on screen. Holds the veil through the
|
* there's no content to protect, so no veil goes up.
|
||||||
* pull plus a short settle so per-tile hydration lands behind it, then
|
|
||||||
* lets it wipe off. Overlapping automatic refreshes are rare enough
|
|
||||||
* (once-daily rebuild, reconnect) that a plain flag beats a counter.
|
|
||||||
*/
|
*/
|
||||||
private fun refreshBehindVeil() {
|
fun retry() = veil.request(userInitiated = true)
|
||||||
viewModelScope.launch {
|
|
||||||
updatingInternal.value = true
|
/**
|
||||||
try {
|
* Manual pull-to-refresh. Goes behind the veil like every other refresh
|
||||||
refresh().join()
|
* (operator call, 2026-07-31: the churn a pull causes is identical to
|
||||||
delay(VEIL_SETTLE_MS)
|
* the automatic paths, and a small spinner didn't hide it).
|
||||||
} finally {
|
*
|
||||||
updatingInternal.value = false
|
* 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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,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 ──────────────────────────────────────────────────────────
|
||||||
@@ -454,14 +579,20 @@ fun HomeScreen(
|
|||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
val updating by viewModel.isUpdating.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),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
// Every cover below reports its load state to the tracker,
|
||||||
// Automatic-refresh veil: the daily rebuild / reconnect
|
// so the veil can wait for artwork instead of guessing.
|
||||||
// churn hides behind an "Updating your mixes…" wipe. Manual
|
CompositionLocalProvider(
|
||||||
// pull owns the PullToRefreshBox spinner instead.
|
LocalArtSettleTracker provides viewModel.artTracker,
|
||||||
|
) {
|
||||||
|
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||||
|
}
|
||||||
|
// 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)
|
UpdatingVeil(visible = updating)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,7 +630,7 @@ private fun HomeStateCrossfade(
|
|||||||
is UiState.Error -> ErrorRetry(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load home",
|
title = "Couldn't load home",
|
||||||
message = s.message,
|
message = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
onRetry = { viewModel.retry() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HomeSuccessContent(
|
is UiState.Success -> HomeSuccessContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ 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,
|
private val likes: LikesRepository,
|
||||||
@@ -55,6 +56,9 @@ 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) {
|
fun toggleLikeTrack(trackId: String) {
|
||||||
val desired = trackId !in likedTrackIds.value
|
val desired = trackId !in likedTrackIds.value
|
||||||
|
|||||||
@@ -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,23 +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 com.fabledsword.minstrel.shared.widgets.LikeButton
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -50,12 +52,30 @@ fun QueueScreen(
|
|||||||
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 ->
|
||||||
@@ -72,12 +92,15 @@ fun QueueScreen(
|
|||||||
likedTrackIds = likedTrackIds,
|
likedTrackIds = likedTrackIds,
|
||||||
onJumpTo = viewModel::seekToIndex,
|
onJumpTo = viewModel::seekToIndex,
|
||||||
onToggleLike = viewModel::toggleLikeTrack,
|
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>,
|
||||||
@@ -85,84 +108,87 @@ private fun QueueList(
|
|||||||
likedTrackIds: Set<String>,
|
likedTrackIds: Set<String>,
|
||||||
onJumpTo: (Int) -> Unit,
|
onJumpTo: (Int) -> Unit,
|
||||||
onToggleLike: (String) -> 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,
|
|
||||||
liked = track.id in likedTrackIds,
|
// Follow the now-playing row as the track auto-advances, but only while it's
|
||||||
onClick = { onJumpTo(index) },
|
// near the visible window — if the user has scrolled away to browse, leave
|
||||||
onToggleLike = { onToggleLike(track.id) },
|
// them there (the pill offers the way back). Parity with the web queue.
|
||||||
)
|
LaunchedEffect(currentIndex) {
|
||||||
HorizontalDivider()
|
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(
|
private fun JumpToCurrentPill(
|
||||||
track: TrackRef,
|
visible: Boolean,
|
||||||
isCurrent: Boolean,
|
|
||||||
liked: Boolean,
|
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onToggleLike: () -> Unit,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val highlight = if (isCurrent) {
|
AnimatedVisibility(visible = visible, modifier = modifier) {
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
FilledTonalButton(onClick = onClick) {
|
||||||
} else {
|
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
Color.Transparent
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
}
|
Text("Jump to current")
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(highlight)
|
|
||||||
.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
LikeButton(liked = liked, onToggle = onToggleLike)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "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 }
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,15 +67,30 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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:
|
// tuningSnapshot is both the GET response and the post-mutation echo:
|
||||||
// current values alongside shipped defaults so the card can mark
|
// current values alongside shipped defaults so the card can mark
|
||||||
// which knobs deviate.
|
// which knobs deviate.
|
||||||
type tuningSnapshot struct {
|
type tuningSnapshot struct {
|
||||||
Profiles map[string]weightsResp `json:"profiles"`
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
Taste tasteTuningResp `json:"taste"`
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Discover discoverTuningResp `json:"discover"`
|
||||||
Shipped struct {
|
Shipped struct {
|
||||||
Profiles map[string]weightsResp `json:"profiles"`
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
Taste tasteTuningResp `json:"taste"`
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Discover discoverTuningResp `json:"discover"`
|
||||||
} `json:"shipped"`
|
} `json:"shipped"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,11 +101,13 @@ func (h *handlers) tuningSnapshot() tuningSnapshot {
|
|||||||
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||||
}
|
}
|
||||||
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||||
|
out.Discover = discoverRespFrom(h.recSettings.Discover())
|
||||||
out.Shipped.Profiles = map[string]weightsResp{
|
out.Shipped.Profiles = map[string]weightsResp{
|
||||||
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||||
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||||
}
|
}
|
||||||
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||||
|
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +137,12 @@ func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *htt
|
|||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if scope == recsettings.ScopeTaste {
|
switch scope {
|
||||||
|
case recsettings.ScopeTaste:
|
||||||
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
||||||
} else {
|
case recsettings.ScopeDiscover:
|
||||||
|
err = h.recSettings.UpdateDiscover(r.Context(), body.Values)
|
||||||
|
default:
|
||||||
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
|
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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/recsettings"
|
||||||
@@ -30,7 +31,7 @@ import (
|
|||||||
// 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, 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) {
|
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,
|
||||||
@@ -51,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) {
|
||||||
@@ -74,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)
|
||||||
@@ -87,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)
|
||||||
@@ -97,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
|
||||||
@@ -104,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)
|
||||||
@@ -177,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)
|
||||||
|
|
||||||
@@ -256,6 +274,9 @@ type handlers struct {
|
|||||||
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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,70 @@
|
|||||||
|
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 tag's own 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.
|
||||||
|
//
|
||||||
|
// The first look at that spread found it dominated by welded tokens like
|
||||||
|
// "Alternative RockRock" — the scanner's own bug, not the operator's tagging
|
||||||
|
// (#2499). Judge the "is a taxonomy needed" question (#2468) only against a
|
||||||
|
// library re-scanned since that fix.
|
||||||
|
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.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)
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The hop arithmetic is the whole feature, so the table is written as
|
||||||
|
// deployment topologies rather than abstract inputs.
|
||||||
|
func TestClientIP(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hops int
|
||||||
|
remoteAddr string
|
||||||
|
forwarded string
|
||||||
|
realIP string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no proxy configured, socket peer wins",
|
||||||
|
hops: 0,
|
||||||
|
remoteAddr: "203.0.113.5:51234",
|
||||||
|
want: "203.0.113.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// hops 0 is the setting for a directly-exposed instance, and it
|
||||||
|
// must make forged headers inert.
|
||||||
|
name: "hops 0 ignores a forged forwarded header",
|
||||||
|
hops: 0,
|
||||||
|
remoteAddr: "203.0.113.5:51234",
|
||||||
|
forwarded: "198.51.100.99",
|
||||||
|
want: "203.0.113.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The common case: one TLS-terminating proxy. Note RemoteAddr is
|
||||||
|
// PUBLIC here — a proxy on its own host — which the previous
|
||||||
|
// private-range heuristic got wrong.
|
||||||
|
name: "one proxy on a public address yields the client",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "203.0.113.200:40000",
|
||||||
|
forwarded: "198.51.100.7",
|
||||||
|
want: "198.51.100.7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one proxy on a private address yields the client",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "172.18.0.1:40000",
|
||||||
|
forwarded: "198.51.100.7",
|
||||||
|
want: "198.51.100.7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// client -> Cloudflare -> own proxy -> app.
|
||||||
|
// Trusting only our own proxy, the honest answer is Cloudflare:
|
||||||
|
// that's the address our proxy actually observed.
|
||||||
|
name: "cdn chain with hops 1 stops at the cdn",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "172.18.0.1:40000",
|
||||||
|
forwarded: "198.51.100.7, 203.0.113.50",
|
||||||
|
want: "203.0.113.50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Same chain, both hops trusted — now we reach the real client.
|
||||||
|
name: "cdn chain with hops 2 reaches the client",
|
||||||
|
hops: 2,
|
||||||
|
remoteAddr: "172.18.0.1:40000",
|
||||||
|
forwarded: "198.51.100.7, 203.0.113.50",
|
||||||
|
want: "198.51.100.7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A client prepending a lie is only reachable if the operator
|
||||||
|
// over-counts their proxies; at the correct depth it's skipped.
|
||||||
|
name: "forged prefix is not reached at the correct depth",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "172.18.0.1:40000",
|
||||||
|
forwarded: "1.2.3.4, 198.51.100.7",
|
||||||
|
want: "198.51.100.7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The documented mis-set failure, pinned so it stays a KNOWN
|
||||||
|
// consequence rather than a surprise: depth deeper than the real
|
||||||
|
// chain reads attacker-supplied input.
|
||||||
|
name: "hops set deeper than the chain clamps to the leftmost entry",
|
||||||
|
hops: 5,
|
||||||
|
remoteAddr: "172.18.0.1:40000",
|
||||||
|
forwarded: "1.2.3.4, 198.51.100.7",
|
||||||
|
want: "1.2.3.4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no forwarding header falls back to the socket peer",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "203.0.113.5:51234",
|
||||||
|
want: "203.0.113.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "x-real-ip used when forwarded-for is absent",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "172.18.0.1:40000",
|
||||||
|
realIP: "198.51.100.7",
|
||||||
|
want: "198.51.100.7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forwarded-for wins over x-real-ip when both present",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "172.18.0.1:40000",
|
||||||
|
forwarded: "198.51.100.7",
|
||||||
|
realIP: "1.2.3.4",
|
||||||
|
want: "198.51.100.7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Positions are preserved, so a garbage hop can be selected —
|
||||||
|
// in which case we fall back rather than return nonsense.
|
||||||
|
name: "unparseable selected entry falls back to the socket peer",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "172.18.0.1:40000",
|
||||||
|
forwarded: "198.51.100.7, not-an-ip",
|
||||||
|
want: "172.18.0.1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv6 client through one proxy",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "[fd00::1]:40000",
|
||||||
|
forwarded: "2001:db8::5",
|
||||||
|
want: "2001:db8::5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ipv6 socket peer without proxy",
|
||||||
|
hops: 0,
|
||||||
|
remoteAddr: "[2001:db8::1]:51234",
|
||||||
|
want: "2001:db8::1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remote addr without a port is tolerated",
|
||||||
|
hops: 0,
|
||||||
|
remoteAddr: "203.0.113.5",
|
||||||
|
want: "203.0.113.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty remote addr yields empty",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "",
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace-only forwarded header is treated as absent",
|
||||||
|
hops: 1,
|
||||||
|
remoteAddr: "172.18.0.1:40000",
|
||||||
|
forwarded: " ",
|
||||||
|
want: "172.18.0.1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r, err := http.NewRequest(http.MethodGet, "/api/me/sessions", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewRequest: %v", err)
|
||||||
|
}
|
||||||
|
r.RemoteAddr = tc.remoteAddr
|
||||||
|
if tc.forwarded != "" {
|
||||||
|
r.Header.Set("X-Forwarded-For", tc.forwarded)
|
||||||
|
}
|
||||||
|
if tc.realIP != "" {
|
||||||
|
r.Header.Set("X-Real-IP", tc.realIP)
|
||||||
|
}
|
||||||
|
if got := ClientIP(r, tc.hops); got != tc.want {
|
||||||
|
t.Errorf("ClientIP(hops=%d) = %q, want %q", tc.hops, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,17 @@ package auth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey int
|
type ctxKey int
|
||||||
|
|
||||||
const userCtxKey ctxKey = 1
|
const (
|
||||||
|
userCtxKey ctxKey = 1
|
||||||
|
sessionIDCtxKey ctxKey = 2
|
||||||
|
)
|
||||||
|
|
||||||
// UserFromContext returns the authenticated user placed in context by
|
// UserFromContext returns the authenticated user placed in context by
|
||||||
// RequireUser. Returns false when RequireUser has not run (e.g. in tests that
|
// RequireUser. Returns false when RequireUser has not run (e.g. in tests that
|
||||||
@@ -17,3 +22,13 @@ func UserFromContext(ctx context.Context) (dbq.User, bool) {
|
|||||||
u, ok := ctx.Value(userCtxKey).(dbq.User)
|
u, ok := ctx.Value(userCtxKey).(dbq.User)
|
||||||
return u, ok
|
return u, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SessionIDFromContext returns the id of the session that authenticated this
|
||||||
|
// request. The active-sessions surface needs it for the two things it can't
|
||||||
|
// do from the user alone: mark which row is "this device", and exclude that
|
||||||
|
// row from "log out everywhere else" so the action doesn't sign the caller
|
||||||
|
// out of the page they invoked it from.
|
||||||
|
func SessionIDFromContext(ctx context.Context) (pgtype.UUID, bool) {
|
||||||
|
id, ok := ctx.Value(sessionIDCtxKey).(pgtype.UUID)
|
||||||
|
return id, ok
|
||||||
|
}
|
||||||
|
|||||||
@@ -56,7 +56,13 @@ const SessionCookieName = "minstrel_session"
|
|||||||
// bearer header and puts the dbq.User in request context via userCtxKey.
|
// bearer header and puts the dbq.User in request context via userCtxKey.
|
||||||
// Requests without a valid session return 401 with no body so callers don't
|
// Requests without a valid session return 401 with no body so callers don't
|
||||||
// leak whether the username existed (matches the /rest/* auth posture).
|
// leak whether the username existed (matches the /rest/* auth posture).
|
||||||
func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
|
//
|
||||||
|
// trustedHops supplies the reverse-proxy depth used to record the session's
|
||||||
|
// current address (#2453). It's a func rather than an int because the value
|
||||||
|
// is operator-editable at runtime and this middleware is constructed once at
|
||||||
|
// boot — reading it per request is what makes an admin change take effect
|
||||||
|
// without a restart. Passing nil means "trust nothing", i.e. the socket peer.
|
||||||
|
func RequireUser(pool *pgxpool.Pool, trustedHops func() int) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
token := sessionTokenFromRequest(r)
|
token := sessionTokenFromRequest(r)
|
||||||
@@ -98,10 +104,17 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
// Best-effort last-seen update. A failure here shouldn't fail the
|
// Best-effort last-seen update. A failure here shouldn't fail the
|
||||||
// request; the session is still valid and this is observability.
|
// request; the session is still valid and this is observability.
|
||||||
if err := q.TouchSessionLastSeen(r.Context(), sess.ID); err != nil {
|
// last_ip rides the same UPDATE — a session whose address has
|
||||||
|
// moved since it was issued is the signal the active-sessions
|
||||||
|
// surface exists to show, and it costs nothing extra here.
|
||||||
|
if err := q.TouchSessionLastSeen(r.Context(), dbq.TouchSessionLastSeenParams{
|
||||||
|
ID: sess.ID,
|
||||||
|
LastIp: ClientIP(r, hopsOf(trustedHops)),
|
||||||
|
}); err != nil {
|
||||||
slog.Warn("api: touch session last_seen failed", "err", err)
|
slog.Warn("api: touch session last_seen failed", "err", err)
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
||||||
|
ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -112,6 +125,12 @@ func RequireUser(pool *pgxpool.Pool) func(http.Handler) http.Handler {
|
|||||||
// middleware. Do not use this outside _test.go files.
|
// middleware. Do not use this outside _test.go files.
|
||||||
func UserCtxKeyForTest() any { return userCtxKey }
|
func UserCtxKeyForTest() any { return userCtxKey }
|
||||||
|
|
||||||
|
// SessionIDCtxKeyForTest is the sibling of UserCtxKeyForTest for the session
|
||||||
|
// id, so handler tests can exercise the current-session logic (which row is
|
||||||
|
// "this device", which one logout-others must spare) without standing up the
|
||||||
|
// middleware. Do not use this outside _test.go files.
|
||||||
|
func SessionIDCtxKeyForTest() any { return sessionIDCtxKey }
|
||||||
|
|
||||||
// OptionalUser is RequireUser's permissive sibling: it resolves the caller
|
// OptionalUser is RequireUser's permissive sibling: it resolves the caller
|
||||||
// from the session cookie or bearer header and attaches the user to context
|
// from the session cookie or bearer header and attaches the user to context
|
||||||
// when present + valid, but does NOT 401 on absence. The downstream handler
|
// when present + valid, but does NOT 401 on absence. The downstream handler
|
||||||
@@ -153,6 +172,7 @@ func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) ht
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
ctx := context.WithValue(r.Context(), userCtxKey, user)
|
||||||
|
ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestRequireUser_RejectsWhenNoCookieOrBearer(t *testing.T) {
|
|||||||
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
next := http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
t.Fatal("handler must not be called")
|
t.Fatal("handler must not be called")
|
||||||
})
|
})
|
||||||
h := RequireUser(nil)(next)
|
h := RequireUser(nil, nil)(next)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/me", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -478,23 +478,40 @@ func (q *Queries) ListAlbumsByArtistWithTrackCount(ctx context.Context, artistID
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
const listAlbumsByGenre = `-- name: ListAlbumsByGenre :many
|
||||||
SELECT DISTINCT ON (albums.id) albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version
|
||||||
FROM albums
|
FROM albums
|
||||||
JOIN tracks ON tracks.album_id = albums.id
|
WHERE EXISTS (
|
||||||
WHERE tracks.genre = $1
|
SELECT 1
|
||||||
ORDER BY albums.id, albums.sort_title
|
FROM tracks
|
||||||
LIMIT $2 OFFSET $3
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.album_id = albums.id
|
||||||
|
AND trim(g.genre) = trim($1::text)
|
||||||
|
)
|
||||||
|
ORDER BY albums.sort_title, albums.id
|
||||||
|
LIMIT $3 OFFSET $2
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListAlbumsByGenreParams struct {
|
type ListAlbumsByGenreParams struct {
|
||||||
Genre *string
|
Genre string
|
||||||
Limit int32
|
Off int32
|
||||||
Offset int32
|
Lim int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album "belongs to" a genre if any of its tracks carry that genre.
|
// Album "belongs to" a genre if any of its tracks carry that genre.
|
||||||
|
// Serves Subsonic getAlbumList?type=byGenre.
|
||||||
|
//
|
||||||
|
// Splits tracks.genre on [;,] as of #367. It previously compared the whole
|
||||||
|
// column verbatim, so a track tagged "Rock;Pop" was unreachable from EITHER
|
||||||
|
// "Rock" or "Pop" — a Subsonic client asking for a genre silently missed
|
||||||
|
// every multi-genre track. This also aligns the endpoint with
|
||||||
|
// recommendation.sql / discover.sql, which have always split, and with the
|
||||||
|
// genre browse index that #367 adds.
|
||||||
|
//
|
||||||
|
// EXISTS rather than JOIN + DISTINCT ON: the lateral split emits one row per
|
||||||
|
// (track, genre-fragment), so a join would multiply rows per album and lean
|
||||||
|
// on DISTINCT to undo it. EXISTS asks the question directly.
|
||||||
func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenreParams) ([]Album, error) {
|
func (q *Queries) ListAlbumsByGenre(ctx context.Context, arg ListAlbumsByGenreParams) ([]Album, error) {
|
||||||
rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Limit, arg.Offset)
|
rows, err := q.db.Query(ctx, listAlbumsByGenre, arg.Genre, arg.Off, arg.Lim)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: browse.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const countAlbumsByGenre = `-- name: CountAlbumsByGenre :one
|
||||||
|
SELECT COUNT(*) FROM albums
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.album_id = albums.id
|
||||||
|
AND trim(g.genre) = trim($1::text)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Total for the paging envelope. EXISTS mirrors the list query exactly; a
|
||||||
|
// JOIN + DISTINCT here would count differently the moment an album has two
|
||||||
|
// tracks carrying the same genre.
|
||||||
|
func (q *Queries) CountAlbumsByGenre(ctx context.Context, genre string) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countAlbumsByGenre, genre)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const countAlbumsByYearRange = `-- name: CountAlbumsByYearRange :one
|
||||||
|
SELECT COUNT(*) FROM albums
|
||||||
|
WHERE release_date IS NOT NULL
|
||||||
|
AND EXTRACT(YEAR FROM release_date)::int
|
||||||
|
BETWEEN $1::int AND $2::int
|
||||||
|
`
|
||||||
|
|
||||||
|
type CountAlbumsByYearRangeParams struct {
|
||||||
|
YearFrom int32
|
||||||
|
YearTo int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) CountAlbumsByYearRange(ctx context.Context, arg CountAlbumsByYearRangeParams) (int64, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countAlbumsByYearRange, arg.YearFrom, arg.YearTo)
|
||||||
|
var count int64
|
||||||
|
err := row.Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAlbumYearsWithCount = `-- name: ListAlbumYearsWithCount :many
|
||||||
|
SELECT EXTRACT(YEAR FROM release_date)::int AS year, COUNT(*)::bigint AS album_count
|
||||||
|
FROM albums
|
||||||
|
WHERE release_date IS NOT NULL
|
||||||
|
GROUP BY year
|
||||||
|
ORDER BY year DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAlbumYearsWithCountRow struct {
|
||||||
|
Year int32
|
||||||
|
AlbumCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Year browse index (#367). Only albums with a release_date appear — an
|
||||||
|
// album with no date isn't "year unknown" as a browsable bucket, it's absent
|
||||||
|
// from this axis, and the UI says so rather than inventing a 0 row.
|
||||||
|
// Newest first: recent releases are the likelier browse target.
|
||||||
|
func (q *Queries) ListAlbumYearsWithCount(ctx context.Context) ([]ListAlbumYearsWithCountRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlbumYearsWithCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListAlbumYearsWithCountRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListAlbumYearsWithCountRow
|
||||||
|
if err := rows.Scan(&i.Year, &i.AlbumCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAlbumsByGenreWithArtist = `-- name: ListAlbumsByGenreWithArtist :many
|
||||||
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||||
|
FROM albums
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.album_id = albums.id
|
||||||
|
AND trim(g.genre) = trim($1::text)
|
||||||
|
)
|
||||||
|
ORDER BY albums.sort_title, albums.id
|
||||||
|
LIMIT $3 OFFSET $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAlbumsByGenreWithArtistParams struct {
|
||||||
|
Genre string
|
||||||
|
Off int32
|
||||||
|
Lim int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListAlbumsByGenreWithArtistRow struct {
|
||||||
|
Album Album
|
||||||
|
ArtistName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Albums for one genre, joined with artist_name for the browse grid.
|
||||||
|
// An album belongs to a genre when ANY of its tracks carry it. Splits and
|
||||||
|
// trims identically to ListGenresWithCount — if the list is built by
|
||||||
|
// splitting and the detail matched exactly, every multi-genre track would
|
||||||
|
// produce a genre row that leads to an empty page.
|
||||||
|
func (q *Queries) ListAlbumsByGenreWithArtist(ctx context.Context, arg ListAlbumsByGenreWithArtistParams) ([]ListAlbumsByGenreWithArtistRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlbumsByGenreWithArtist, arg.Genre, arg.Off, arg.Lim)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListAlbumsByGenreWithArtistRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListAlbumsByGenreWithArtistRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Album.ID,
|
||||||
|
&i.Album.Title,
|
||||||
|
&i.Album.SortTitle,
|
||||||
|
&i.Album.ArtistID,
|
||||||
|
&i.Album.ReleaseDate,
|
||||||
|
&i.Album.Mbid,
|
||||||
|
&i.Album.CoverArtPath,
|
||||||
|
&i.Album.CreatedAt,
|
||||||
|
&i.Album.UpdatedAt,
|
||||||
|
&i.Album.CoverArtSource,
|
||||||
|
&i.Album.CoverArtSourcesVersion,
|
||||||
|
&i.ArtistName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAlbumsByYearRangeWithArtist = `-- name: ListAlbumsByYearRangeWithArtist :many
|
||||||
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||||
|
FROM albums
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
WHERE albums.release_date IS NOT NULL
|
||||||
|
AND EXTRACT(YEAR FROM albums.release_date)::int
|
||||||
|
BETWEEN $1::int AND $2::int
|
||||||
|
ORDER BY albums.sort_title, albums.id
|
||||||
|
LIMIT $4 OFFSET $3
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListAlbumsByYearRangeWithArtistParams struct {
|
||||||
|
YearFrom int32
|
||||||
|
YearTo int32
|
||||||
|
Off int32
|
||||||
|
Lim int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListAlbumsByYearRangeWithArtistRow struct {
|
||||||
|
Album Album
|
||||||
|
ArtistName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Albums released within an inclusive year range, for the albums-page filter.
|
||||||
|
func (q *Queries) ListAlbumsByYearRangeWithArtist(ctx context.Context, arg ListAlbumsByYearRangeWithArtistParams) ([]ListAlbumsByYearRangeWithArtistRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listAlbumsByYearRangeWithArtist,
|
||||||
|
arg.YearFrom,
|
||||||
|
arg.YearTo,
|
||||||
|
arg.Off,
|
||||||
|
arg.Lim,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListAlbumsByYearRangeWithArtistRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListAlbumsByYearRangeWithArtistRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Album.ID,
|
||||||
|
&i.Album.Title,
|
||||||
|
&i.Album.SortTitle,
|
||||||
|
&i.Album.ArtistID,
|
||||||
|
&i.Album.ReleaseDate,
|
||||||
|
&i.Album.Mbid,
|
||||||
|
&i.Album.CoverArtPath,
|
||||||
|
&i.Album.CreatedAt,
|
||||||
|
&i.Album.UpdatedAt,
|
||||||
|
&i.Album.CoverArtSource,
|
||||||
|
&i.Album.CoverArtSourcesVersion,
|
||||||
|
&i.ArtistName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listGenresForAlbum = `-- name: ListGenresForAlbum :many
|
||||||
|
SELECT DISTINCT trim(g.genre) AS genre
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.album_id = $1 AND trim(g.genre) <> ''
|
||||||
|
ORDER BY trim(g.genre)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Distinct genres carried by an album's tracks, for the album detail page's
|
||||||
|
// quick-jump chips. Split and trimmed identically to ListGenresWithCount, so a
|
||||||
|
// chip always leads to a page that actually contains this album — the two
|
||||||
|
// diverging is exactly the bug #367 had to fix in ListAlbumsByGenre.
|
||||||
|
func (q *Queries) ListGenresForAlbum(ctx context.Context, albumID pgtype.UUID) ([]string, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listGenresForAlbum, albumID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []string
|
||||||
|
for rows.Next() {
|
||||||
|
var genre string
|
||||||
|
if err := rows.Scan(&genre); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, genre)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listGenresForArtist = `-- name: ListGenresForArtist :many
|
||||||
|
SELECT DISTINCT trim(g.genre) AS genre
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE tracks.artist_id = $1 AND trim(g.genre) <> ''
|
||||||
|
ORDER BY trim(g.genre)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Same, across everything by one artist. Alphabetical rather than by count:
|
||||||
|
// an artist's genre set is small, and a stable order reads better than a
|
||||||
|
// frequency ranking nobody asked about.
|
||||||
|
func (q *Queries) ListGenresForArtist(ctx context.Context, artistID pgtype.UUID) ([]string, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listGenresForArtist, artistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []string
|
||||||
|
for rows.Next() {
|
||||||
|
var genre string
|
||||||
|
if err := rows.Scan(&genre); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, genre)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listGenresWithCount = `-- name: ListGenresWithCount :many
|
||||||
|
SELECT trim(g.genre) AS genre, COUNT(DISTINCT tracks.id)::bigint AS track_count
|
||||||
|
FROM tracks
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(tracks.genre, ''), '[;,]') AS g(genre) ON true
|
||||||
|
WHERE trim(g.genre) <> ''
|
||||||
|
GROUP BY trim(g.genre)
|
||||||
|
ORDER BY track_count DESC, trim(g.genre)
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListGenresWithCountRow struct {
|
||||||
|
Genre string
|
||||||
|
TrackCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Genre browse index (#367).
|
||||||
|
//
|
||||||
|
// Genres live inline on tracks.genre as a delimited string, so this splits on
|
||||||
|
// the same [;,] pattern already used by recommendation.sql and discover.sql —
|
||||||
|
// a track tagged "Rock;Pop" must count toward both, and diverging from the
|
||||||
|
// established pattern here would make the browse surface disagree with what
|
||||||
|
// the recommendation engine believes the library contains.
|
||||||
|
//
|
||||||
|
// trim() but deliberately NO lower(): trimming repairs an artifact of OUR
|
||||||
|
// splitting ("Rock; Pop" yields " Pop", and showing that as a distinct genre
|
||||||
|
// would be a bug), whereas case is what the tag actually says. Raw ID3 is
|
||||||
|
// exposed as-is for v1, so "Rock" and "rock" appear as separate rows.
|
||||||
|
//
|
||||||
|
// COUNT(DISTINCT) because a sloppy tag like "Rock;Rock" would otherwise
|
||||||
|
// inflate its own row.
|
||||||
|
//
|
||||||
|
// Ordered by count first: raw ID3 data has a long tail of one-off junk tags,
|
||||||
|
// so alphabetical would bury the handful of genres an operator actually has a
|
||||||
|
// library's worth of. Name breaks ties for a stable order.
|
||||||
|
// Ordered by the expression, not the output alias: `ORDER BY genre` is
|
||||||
|
// ambiguous between the alias and tracks.genre, and sqlc rejects it.
|
||||||
|
func (q *Queries) ListGenresWithCount(ctx context.Context) ([]ListGenresWithCountRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listGenresWithCount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListGenresWithCountRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListGenresWithCountRow
|
||||||
|
if err := rows.Scan(&i.Genre, &i.TrackCount); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: candidate_artist_tags.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const countCandidateArtistTagCoverage = `-- name: CountCandidateArtistTagCoverage :one
|
||||||
|
SELECT count(*)::bigint AS processed,
|
||||||
|
count(*) FILTER (WHERE tag_source <> 'none')::bigint AS with_tags
|
||||||
|
FROM candidate_artist_tag_state
|
||||||
|
`
|
||||||
|
|
||||||
|
type CountCandidateArtistTagCoverageRow struct {
|
||||||
|
Processed int64
|
||||||
|
WithTags int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operator-facing coverage: how many distinct candidates have been processed,
|
||||||
|
// and how many of those actually yielded tags. The gap is the honest ceiling
|
||||||
|
// from the task — obscure artists with no MBID presence or no upstream tags
|
||||||
|
// stay thin no matter how long the worker runs, and that is worth being able
|
||||||
|
// to see rather than inferring from a silent surface.
|
||||||
|
func (q *Queries) CountCandidateArtistTagCoverage(ctx context.Context) (CountCandidateArtistTagCoverageRow, error) {
|
||||||
|
row := q.db.QueryRow(ctx, countCandidateArtistTagCoverage)
|
||||||
|
var i CountCandidateArtistTagCoverageRow
|
||||||
|
err := row.Scan(&i.Processed, &i.WithTags)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteCandidateArtistTags = `-- name: DeleteCandidateArtistTags :exec
|
||||||
|
DELETE FROM candidate_artist_tags WHERE candidate_mbid = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Clear a candidate's cached tags before rewriting (atomic replace by the
|
||||||
|
// caller, same shape as DeleteTrackTags).
|
||||||
|
func (q *Queries) DeleteCandidateArtistTags(ctx context.Context, candidateMbid string) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteCandidateArtistTags, candidateMbid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const gcDeleteOrphanedCandidateArtistTagState = `-- name: GcDeleteOrphanedCandidateArtistTagState :execrows
|
||||||
|
DELETE FROM candidate_artist_tag_state s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM artist_similarity_unmatched u
|
||||||
|
WHERE u.candidate_mbid = s.candidate_mbid
|
||||||
|
)
|
||||||
|
OR EXISTS (SELECT 1 FROM artists a WHERE a.mbid = s.candidate_mbid)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Same sweep for the bookkeeping rows. Kept as a separate statement rather
|
||||||
|
// than a cascade: the two tables are independent by design (a 'none' outcome
|
||||||
|
// has state but no tags), so neither can be the parent of the other.
|
||||||
|
func (q *Queries) GcDeleteOrphanedCandidateArtistTagState(ctx context.Context) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, gcDeleteOrphanedCandidateArtistTagState)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const gcDeleteOrphanedCandidateArtistTags = `-- name: GcDeleteOrphanedCandidateArtistTags :execrows
|
||||||
|
DELETE FROM candidate_artist_tags t
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM artist_similarity_unmatched u
|
||||||
|
WHERE u.candidate_mbid = t.candidate_mbid
|
||||||
|
)
|
||||||
|
OR EXISTS (SELECT 1 FROM artists a WHERE a.mbid = t.candidate_mbid)
|
||||||
|
`
|
||||||
|
|
||||||
|
// Drops cached tags for candidates that no longer appear in the similarity
|
||||||
|
// feed, or that have since been added to the library (their tags now live in
|
||||||
|
// track_tags). The feed is refetched periodically and churns, so without this
|
||||||
|
// the cache only ever grows.
|
||||||
|
func (q *Queries) GcDeleteOrphanedCandidateArtistTags(ctx context.Context) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, gcDeleteOrphanedCandidateArtistTags)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertCandidateArtistTag = `-- name: InsertCandidateArtistTag :exec
|
||||||
|
INSERT INTO candidate_artist_tags (candidate_mbid, tag, weight)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (candidate_mbid, tag)
|
||||||
|
DO UPDATE SET weight = GREATEST(candidate_artist_tags.weight, EXCLUDED.weight)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertCandidateArtistTagParams struct {
|
||||||
|
CandidateMbid string
|
||||||
|
Tag string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert one (candidate, tag); keep the stronger weight when two providers
|
||||||
|
// agree on a tag with different folksonomy strengths.
|
||||||
|
func (q *Queries) InsertCandidateArtistTag(ctx context.Context, arg InsertCandidateArtistTagParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertCandidateArtistTag, arg.CandidateMbid, arg.Tag, arg.Weight)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listCandidateArtistTagsForMbids = `-- name: ListCandidateArtistTagsForMbids :many
|
||||||
|
SELECT candidate_mbid, tag, weight
|
||||||
|
FROM candidate_artist_tags
|
||||||
|
WHERE candidate_mbid = ANY($1::text[])
|
||||||
|
`
|
||||||
|
|
||||||
|
// Cached tags for a set of candidates, for slice 6's taste-overlap ranking.
|
||||||
|
// One row per (candidate, tag).
|
||||||
|
func (q *Queries) ListCandidateArtistTagsForMbids(ctx context.Context, dollar_1 []string) ([]CandidateArtistTag, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listCandidateArtistTagsForMbids, dollar_1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []CandidateArtistTag
|
||||||
|
for rows.Next() {
|
||||||
|
var i CandidateArtistTag
|
||||||
|
if err := rows.Scan(&i.CandidateMbid, &i.Tag, &i.Weight); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listCandidateArtistsMissingTags = `-- name: ListCandidateArtistsMissingTags :many
|
||||||
|
|
||||||
|
SELECT u.candidate_mbid,
|
||||||
|
coalesce(max(u.candidate_name), '')::text AS candidate_name,
|
||||||
|
sum(u.score)::float8 AS total_score
|
||||||
|
FROM artist_similarity_unmatched u
|
||||||
|
LEFT JOIN candidate_artist_tag_state s ON s.candidate_mbid = u.candidate_mbid
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM artists a WHERE a.mbid = u.candidate_mbid)
|
||||||
|
AND (
|
||||||
|
s.candidate_mbid IS NULL
|
||||||
|
OR (s.tag_source = 'none' AND s.tag_sources_version < $1)
|
||||||
|
)
|
||||||
|
GROUP BY u.candidate_mbid
|
||||||
|
ORDER BY total_score DESC, u.candidate_mbid
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListCandidateArtistsMissingTagsParams struct {
|
||||||
|
TagSourcesVersion int32
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListCandidateArtistsMissingTagsRow struct {
|
||||||
|
CandidateMbid string
|
||||||
|
CandidateName string
|
||||||
|
TotalScore float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folksonomy tags for out-of-library Discover candidates (#2376). Parallel to
|
||||||
|
// track_tags.sql, but keyed by MBID because the artist has no local row. See
|
||||||
|
// 0050_candidate_artist_tags.up.sql for why the bookkeeping is its own table.
|
||||||
|
// Candidates eligible for tag enrichment: never processed (no state row) or
|
||||||
|
// settled 'none' under an older provider version.
|
||||||
|
//
|
||||||
|
// artist_similarity_unmatched holds one row per (seed, candidate, source), so
|
||||||
|
// this GROUPs to one row per candidate — enriching the same MBID once per seed
|
||||||
|
// that pointed at it would multiply the API calls for no gain.
|
||||||
|
//
|
||||||
|
// ORDER BY summed similarity DESC is the load-bearing part. The candidate pool
|
||||||
|
// is O(library artists x neighbours per artist) — thousands — and MusicBrainz
|
||||||
|
// allows ~1 req/s, so it can NEVER be fully enriched in one pass. Draining in
|
||||||
|
// strength order means the candidates most likely to actually reach a user's
|
||||||
|
// deck get tags first, and the long tail fills in over subsequent ticks
|
||||||
|
// instead of starving behind it.
|
||||||
|
//
|
||||||
|
// Already-in-library candidates are skipped: they have an artists row, so
|
||||||
|
// their tags belong in track_tags, and the suggestion query filters them out
|
||||||
|
// anyway. $1 = current tag_sources_version, $2 = limit.
|
||||||
|
// candidate_name is coalesced to the empty string so it lands non-nullable in
|
||||||
|
// Go: the name is only a Last.fm lookup key, and empty simply means "MBID-keyed
|
||||||
|
// providers only", which the provider chain already handles. max() is an
|
||||||
|
// arbitrary-but-deterministic pick when several seeds spell one MBID
|
||||||
|
// differently.
|
||||||
|
func (q *Queries) ListCandidateArtistsMissingTags(ctx context.Context, arg ListCandidateArtistsMissingTagsParams) ([]ListCandidateArtistsMissingTagsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listCandidateArtistsMissingTags, arg.TagSourcesVersion, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListCandidateArtistsMissingTagsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListCandidateArtistsMissingTagsRow
|
||||||
|
if err := rows.Scan(&i.CandidateMbid, &i.CandidateName, &i.TotalScore); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const setCandidateArtistTagState = `-- name: SetCandidateArtistTagState :exec
|
||||||
|
INSERT INTO candidate_artist_tag_state (candidate_mbid, tag_source, tag_sources_version)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (candidate_mbid) DO UPDATE
|
||||||
|
SET tag_source = EXCLUDED.tag_source,
|
||||||
|
tag_sources_version = EXCLUDED.tag_sources_version,
|
||||||
|
updated_at = now()
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetCandidateArtistTagStateParams struct {
|
||||||
|
CandidateMbid string
|
||||||
|
TagSource string
|
||||||
|
TagSourcesVersion int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp the enrichment outcome so the drainer skips settled candidates.
|
||||||
|
// $2 = 'musicbrainz' | 'lastfm' | 'mixed' | 'none', $3 = current version.
|
||||||
|
func (q *Queries) SetCandidateArtistTagState(ctx context.Context, arg SetCandidateArtistTagStateParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, setCandidateArtistTagState, arg.CandidateMbid, arg.TagSource, arg.TagSourcesVersion)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -261,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version FROM tracks t
|
||||||
JOIN play_events pe ON pe.track_id = t.id
|
JOIN play_events pe ON pe.track_id = t.id
|
||||||
WHERE pe.session_id = $1
|
WHERE pe.session_id = $1
|
||||||
AND pe.started_at < $2
|
AND pe.started_at < $2
|
||||||
@@ -305,6 +305,7 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
const listUserHistory = `-- name: ListUserHistory :many
|
const listUserHistory = `-- name: ListUserHistory :many
|
||||||
SELECT pe.id AS event_id,
|
SELECT pe.id AS event_id,
|
||||||
pe.started_at,
|
pe.started_at,
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
@@ -79,6 +79,7 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version FROM tracks t
|
||||||
JOIN general_likes l ON l.track_id = t.id
|
JOIN general_likes l ON l.track_id = t.id
|
||||||
WHERE l.user_id = $1
|
WHERE l.user_id = $1
|
||||||
ORDER BY l.liked_at DESC
|
ORDER BY l.liked_at DESC
|
||||||
@@ -299,6 +299,7 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,19 @@ type AuditLog struct {
|
|||||||
CreatedAt pgtype.Timestamptz
|
CreatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CandidateArtistTag struct {
|
||||||
|
CandidateMbid string
|
||||||
|
Tag string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandidateArtistTagState struct {
|
||||||
|
CandidateMbid string
|
||||||
|
TagSource string
|
||||||
|
TagSourcesVersion int32
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type ContextualLike struct {
|
type ContextualLike struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
@@ -277,6 +290,13 @@ type DiagnosticEvent struct {
|
|||||||
ReceivedAt pgtype.Timestamptz
|
ReceivedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DiscoverTuning struct {
|
||||||
|
Singleton bool
|
||||||
|
TagOverlapWeight float64
|
||||||
|
SnoozeDays float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type GeneralLike struct {
|
type GeneralLike struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
@@ -361,6 +381,11 @@ type LidarrRequest struct {
|
|||||||
LidarrAddConfirmedAt pgtype.Timestamptz
|
LidarrAddConfirmedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type NetworkSetting struct {
|
||||||
|
ID bool
|
||||||
|
TrustedProxyHops int32
|
||||||
|
}
|
||||||
|
|
||||||
type PasswordReset struct {
|
type PasswordReset struct {
|
||||||
Token string
|
Token string
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
@@ -494,6 +519,8 @@ type Session struct {
|
|||||||
UserAgent string
|
UserAgent string
|
||||||
CreatedAt pgtype.Timestamptz
|
CreatedAt pgtype.Timestamptz
|
||||||
LastSeenAt pgtype.Timestamptz
|
LastSeenAt pgtype.Timestamptz
|
||||||
|
CreatedIp string
|
||||||
|
LastIp string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SkipEvent struct {
|
type SkipEvent struct {
|
||||||
@@ -518,6 +545,14 @@ type SmtpConfig struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SuggestionSnooze struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
CandidateMbid string
|
||||||
|
CandidateName string
|
||||||
|
SnoozedUntil pgtype.Timestamptz
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type SystemPlaylistRotationState struct {
|
type SystemPlaylistRotationState struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
PlaylistKind string
|
PlaylistKind string
|
||||||
@@ -607,6 +642,7 @@ type Track struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
TagSource *string
|
TagSource *string
|
||||||
TagSourcesVersion int32
|
TagSourcesVersion int32
|
||||||
|
TagReadVersion int16
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackSimilarity struct {
|
type TrackSimilarity struct {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: network_settings.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getNetworkSettings = `-- name: GetNetworkSettings :one
|
||||||
|
SELECT id, trusted_proxy_hops FROM network_settings WHERE id = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetNetworkSettings(ctx context.Context) (NetworkSetting, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getNetworkSettings)
|
||||||
|
var i NetworkSetting
|
||||||
|
err := row.Scan(&i.ID, &i.TrustedProxyHops)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTrustedProxyHops = `-- name: UpdateTrustedProxyHops :one
|
||||||
|
UPDATE network_settings SET trusted_proxy_hops = $1 WHERE id = true RETURNING id, trusted_proxy_hops
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTrustedProxyHops(ctx context.Context, trustedProxyHops int32) (NetworkSetting, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateTrustedProxyHops, trustedProxyHops)
|
||||||
|
var i NetworkSetting
|
||||||
|
err := row.Scan(&i.ID, &i.TrustedProxyHops)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
@@ -208,7 +208,7 @@ WITH plays AS (
|
|||||||
WHERE user_id = $2 AND was_skipped = false
|
WHERE user_id = $2 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -267,6 +267,7 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -287,7 +288,7 @@ WITH plays AS (
|
|||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -348,6 +349,7 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -685,7 +687,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
|
|||||||
|
|
||||||
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
@@ -763,6 +765,7 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
@@ -895,7 +898,7 @@ random_fill AS (
|
|||||||
LIMIT $9
|
LIMIT $9
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
@@ -1004,6 +1007,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
@@ -1022,20 +1026,50 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
||||||
WITH seeds AS (
|
WITH artist_plays AS (
|
||||||
|
-- Completed plays only. The previous seed query counted every play_event,
|
||||||
|
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||||
|
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||||
|
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
profile_seeds AS (
|
||||||
|
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||||
|
),
|
||||||
|
fallback_seeds AS (
|
||||||
SELECT a.id AS artist_id,
|
SELECT a.id AS artist_id,
|
||||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||||
AS signal,
|
AS raw_signal
|
||||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
|
||||||
COUNT(pe.id)::bigint AS play_count
|
|
||||||
FROM artists a
|
FROM artists a
|
||||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
LEFT JOIN play_events pe
|
||||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||||
GROUP BY a.id, gla.artist_id
|
GROUP BY a.id, gla.artist_id
|
||||||
),
|
),
|
||||||
|
seeds AS (
|
||||||
|
SELECT s.artist_id,
|
||||||
|
ln(1.0 + s.raw_signal) AS signal,
|
||||||
|
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||||
|
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||||
|
FROM (
|
||||||
|
SELECT artist_id, raw_signal FROM profile_seeds
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||||
|
) s
|
||||||
|
LEFT JOIN general_likes_artists gla
|
||||||
|
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||||
|
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||||
|
WHERE s.raw_signal > 0
|
||||||
|
),
|
||||||
contributions AS (
|
contributions AS (
|
||||||
SELECT u.candidate_mbid,
|
SELECT u.candidate_mbid,
|
||||||
u.candidate_name,
|
u.candidate_name,
|
||||||
@@ -1052,6 +1086,18 @@ contributions AS (
|
|||||||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||||||
AND r.status NOT IN ('rejected', 'failed')
|
AND r.status NOT IN ('rejected', 'failed')
|
||||||
)
|
)
|
||||||
|
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
|
||||||
|
-- per-user: the candidate returns on its own once snoozed_until
|
||||||
|
-- passes, and stays visible to everyone else meanwhile. Deliberately
|
||||||
|
-- filtered at the candidate stage, NOT folded into the score — a
|
||||||
|
-- snooze carries no opinion about the music, so it must not become a
|
||||||
|
-- ranking signal.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM suggestion_snoozes s
|
||||||
|
WHERE s.user_id = $1
|
||||||
|
AND s.candidate_mbid = u.candidate_mbid
|
||||||
|
AND s.snoozed_until > now()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
SELECT candidate_mbid,
|
SELECT candidate_mbid,
|
||||||
candidate_name,
|
candidate_name,
|
||||||
@@ -1082,13 +1128,24 @@ type SuggestArtistsForUserRow struct {
|
|||||||
TopPlayCounts []int64
|
TopPlayCounts []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// M5c: per-user artist suggestions ranked by signal x similarity. The
|
// Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||||
// seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
// through artist_similarity_unmatched (out-of-library candidates only).
|
||||||
// (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
//
|
||||||
// artist_similarity_unmatched and filters out candidates already in
|
// Seeds are TIERED (rule #131) so the surface never empties:
|
||||||
// library or already in a non-terminal lidarr_request. The outer SELECT
|
//
|
||||||
// aggregates per candidate, returning the top-3 contributing seeds for
|
// tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||||
// attribution. $1=user_id, $2=half_life_days, $3=limit.
|
// SIGNED by internal/taste, so an artist the user has drifted away
|
||||||
|
// from stops contributing instead of accumulating forever.
|
||||||
|
// tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||||
|
// (new account, or before the first daily recompute).
|
||||||
|
//
|
||||||
|
// The signal is log-damped: contribution is signal x similarity, and the old
|
||||||
|
// undamped sum let one heavily-played artist's neighbours take every slot --
|
||||||
|
// entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||||
|
//
|
||||||
|
// Candidates already in the library, already requested and not terminal, or
|
||||||
|
// snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
|
||||||
|
// decay), $3=limit.
|
||||||
func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) {
|
func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
|
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getDiscoverTuning = `-- name: GetDiscoverTuning :one
|
||||||
|
SELECT singleton, tag_overlap_weight, snooze_days, updated_at FROM discover_tuning WHERE singleton = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetDiscoverTuning(ctx context.Context) (DiscoverTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getDiscoverTuning)
|
||||||
|
var i DiscoverTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.TagOverlapWeight,
|
||||||
|
&i.SnoozeDays,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getTasteTuning = `-- name: GetTasteTuning :one
|
const getTasteTuning = `-- name: GetTasteTuning :one
|
||||||
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
|
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
|
||||||
`
|
`
|
||||||
@@ -118,6 +134,32 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateDiscoverTuning = `-- name: UpdateDiscoverTuning :one
|
||||||
|
UPDATE discover_tuning
|
||||||
|
SET tag_overlap_weight = $1,
|
||||||
|
snooze_days = $2,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE singleton = true
|
||||||
|
RETURNING singleton, tag_overlap_weight, snooze_days, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateDiscoverTuningParams struct {
|
||||||
|
TagOverlapWeight float64
|
||||||
|
SnoozeDays float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateDiscoverTuning(ctx context.Context, arg UpdateDiscoverTuningParams) (DiscoverTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateDiscoverTuning, arg.TagOverlapWeight, arg.SnoozeDays)
|
||||||
|
var i DiscoverTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.TagOverlapWeight,
|
||||||
|
&i.SnoozeDays,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const updateTasteTuning = `-- name: UpdateTasteTuning :one
|
const updateTasteTuning = `-- name: UpdateTasteTuning :one
|
||||||
UPDATE taste_tuning
|
UPDATE taste_tuning
|
||||||
SET half_life_days = $1,
|
SET half_life_days = $1,
|
||||||
@@ -226,6 +268,24 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upsertDiscoverTuningDefaults = `-- name: UpsertDiscoverTuningDefaults :exec
|
||||||
|
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
|
||||||
|
VALUES (true, $1, $2)
|
||||||
|
ON CONFLICT (singleton) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertDiscoverTuningDefaultsParams struct {
|
||||||
|
TagOverlapWeight float64
|
||||||
|
SnoozeDays float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot reconcile for the Discover scope (#2377). Never overwrites
|
||||||
|
// operator-tuned values, same contract as the other two.
|
||||||
|
func (q *Queries) UpsertDiscoverTuningDefaults(ctx context.Context, arg UpsertDiscoverTuningDefaultsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertDiscoverTuningDefaults, arg.TagOverlapWeight, arg.SnoozeDays)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||||
INSERT INTO taste_tuning (
|
INSERT INTO taste_tuning (
|
||||||
singleton, half_life_days, engagement_hard_skip,
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
|
|||||||
@@ -11,6 +11,25 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const deleteOtherSessionsForUser = `-- name: DeleteOtherSessionsForUser :execrows
|
||||||
|
DELETE FROM sessions WHERE user_id = $1 AND id <> $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteOtherSessionsForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
ID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Log out everywhere else." Excludes the caller's own session so the action
|
||||||
|
// doesn't log them out of the page they just used to invoke it.
|
||||||
|
func (q *Queries) DeleteOtherSessionsForUser(ctx context.Context, arg DeleteOtherSessionsForUserParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, deleteOtherSessionsForUser, arg.UserID, arg.ID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
const deleteSession = `-- name: DeleteSession :exec
|
const deleteSession = `-- name: DeleteSession :exec
|
||||||
DELETE FROM sessions WHERE id = $1
|
DELETE FROM sessions WHERE id = $1
|
||||||
`
|
`
|
||||||
@@ -29,8 +48,29 @@ func (q *Queries) DeleteSessionByTokenHash(ctx context.Context, tokenHash []byte
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteSessionForUser = `-- name: DeleteSessionForUser :execrows
|
||||||
|
DELETE FROM sessions WHERE id = $1 AND user_id = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteSessionForUserParams struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
UserID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped by user_id, not just id (rule #47). Keyed on the id alone, any
|
||||||
|
// household member could revoke another member's session by guessing a uuid.
|
||||||
|
// execrows lets the handler answer 404 rather than a false 204 when the row
|
||||||
|
// isn't theirs.
|
||||||
|
func (q *Queries) DeleteSessionForUser(ctx context.Context, arg DeleteSessionForUserParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, deleteSessionForUser, arg.ID, arg.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
|
const getSessionByTokenHash = `-- name: GetSessionByTokenHash :one
|
||||||
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at FROM sessions WHERE token_hash = $1
|
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE token_hash = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) {
|
func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) {
|
||||||
@@ -43,24 +83,35 @@ func (q *Queries) GetSessionByTokenHash(ctx context.Context, tokenHash []byte) (
|
|||||||
&i.UserAgent,
|
&i.UserAgent,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.LastSeenAt,
|
&i.LastSeenAt,
|
||||||
|
&i.CreatedIp,
|
||||||
|
&i.LastIp,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const insertSession = `-- name: InsertSession :one
|
const insertSession = `-- name: InsertSession :one
|
||||||
INSERT INTO sessions (user_id, token_hash, user_agent)
|
INSERT INTO sessions (user_id, token_hash, user_agent, created_ip, last_ip)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3, $4, $4)
|
||||||
RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at
|
RETURNING id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertSessionParams struct {
|
type InsertSessionParams struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TokenHash []byte
|
TokenHash []byte
|
||||||
UserAgent string
|
UserAgent string
|
||||||
|
Ip string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// created_ip and last_ip start equal: at issue time the origin IS the current
|
||||||
|
// location. They diverge as the session is used from elsewhere, which is what
|
||||||
|
// makes a stolen token visible in the active-sessions surface.
|
||||||
func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (Session, error) {
|
func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (Session, error) {
|
||||||
row := q.db.QueryRow(ctx, insertSession, arg.UserID, arg.TokenHash, arg.UserAgent)
|
row := q.db.QueryRow(ctx, insertSession,
|
||||||
|
arg.UserID,
|
||||||
|
arg.TokenHash,
|
||||||
|
arg.UserAgent,
|
||||||
|
arg.Ip,
|
||||||
|
)
|
||||||
var i Session
|
var i Session
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.ID,
|
&i.ID,
|
||||||
@@ -69,15 +120,57 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (S
|
|||||||
&i.UserAgent,
|
&i.UserAgent,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
&i.LastSeenAt,
|
&i.LastSeenAt,
|
||||||
|
&i.CreatedIp,
|
||||||
|
&i.LastIp,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const touchSessionLastSeen = `-- name: TouchSessionLastSeen :exec
|
const listSessionsForUser = `-- name: ListSessionsForUser :many
|
||||||
UPDATE sessions SET last_seen_at = now() WHERE id = $1
|
SELECT id, user_id, token_hash, user_agent, created_at, last_seen_at, created_ip, last_ip FROM sessions WHERE user_id = $1 ORDER BY last_seen_at DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) TouchSessionLastSeen(ctx context.Context, id pgtype.UUID) error {
|
// Most-recently-active first: the row a user is most likely to act on is the
|
||||||
_, err := q.db.Exec(ctx, touchSessionLastSeen, id)
|
// one that moved last, and an unfamiliar entry at the top is the alarm.
|
||||||
|
func (q *Queries) ListSessionsForUser(ctx context.Context, userID pgtype.UUID) ([]Session, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listSessionsForUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []Session
|
||||||
|
for rows.Next() {
|
||||||
|
var i Session
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.UserID,
|
||||||
|
&i.TokenHash,
|
||||||
|
&i.UserAgent,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.LastSeenAt,
|
||||||
|
&i.CreatedIp,
|
||||||
|
&i.LastIp,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const touchSessionLastSeen = `-- name: TouchSessionLastSeen :exec
|
||||||
|
UPDATE sessions SET last_seen_at = now(), last_ip = $2 WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type TouchSessionLastSeenParams struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
LastIp string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) TouchSessionLastSeen(ctx context.Context, arg TouchSessionLastSeenParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, touchSessionLastSeen, arg.ID, arg.LastIp)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: suggestion_snoozes.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const gcDeleteExpiredSuggestionSnoozes = `-- name: GcDeleteExpiredSuggestionSnoozes :execrows
|
||||||
|
DELETE FROM suggestion_snoozes WHERE snoozed_until < now()
|
||||||
|
`
|
||||||
|
|
||||||
|
// Keeps the table from growing without bound. Every read already filters on
|
||||||
|
// snoozed_until > now(), so deleting an expired row changes no behaviour —
|
||||||
|
// this is purely reclamation.
|
||||||
|
func (q *Queries) GcDeleteExpiredSuggestionSnoozes(ctx context.Context) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, gcDeleteExpiredSuggestionSnoozes)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listActiveSuggestionSnoozes = `-- name: ListActiveSuggestionSnoozes :many
|
||||||
|
SELECT candidate_mbid, candidate_name, snoozed_until, created_at
|
||||||
|
FROM suggestion_snoozes
|
||||||
|
WHERE user_id = $1 AND snoozed_until > now()
|
||||||
|
ORDER BY snoozed_until, candidate_mbid
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListActiveSuggestionSnoozesRow struct {
|
||||||
|
CandidateMbid string
|
||||||
|
CandidateName string
|
||||||
|
SnoozedUntil pgtype.Timestamptz
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backs the manage / un-snooze surface. Expired rows are filtered HERE
|
||||||
|
// rather than left to the sweeper: gc runs on an hourly tick, so a row can
|
||||||
|
// outlive its expiry by up to a tick and must not read as still-snoozed in
|
||||||
|
// the meantime.
|
||||||
|
func (q *Queries) ListActiveSuggestionSnoozes(ctx context.Context, userID pgtype.UUID) ([]ListActiveSuggestionSnoozesRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listActiveSuggestionSnoozes, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListActiveSuggestionSnoozesRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListActiveSuggestionSnoozesRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.CandidateMbid,
|
||||||
|
&i.CandidateName,
|
||||||
|
&i.SnoozedUntil,
|
||||||
|
&i.CreatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const snoozeSuggestion = `-- name: SnoozeSuggestion :exec
|
||||||
|
|
||||||
|
INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
|
||||||
|
VALUES ($1, $2, $3, now() + ($4::float8 * INTERVAL '1 day'))
|
||||||
|
ON CONFLICT (user_id, candidate_mbid) DO UPDATE
|
||||||
|
SET snoozed_until = EXCLUDED.snoozed_until,
|
||||||
|
candidate_name = EXCLUDED.candidate_name
|
||||||
|
`
|
||||||
|
|
||||||
|
type SnoozeSuggestionParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
CandidateMbid string
|
||||||
|
CandidateName string
|
||||||
|
Column4 float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time-boxed "not right now" on a Discover artist suggestion (#2374).
|
||||||
|
//
|
||||||
|
// See 0049_suggestion_snoozes.up.sql for why this is a snooze and not a
|
||||||
|
// dismissal: it records no verdict on the music, expires on its own, and
|
||||||
|
// must never reach the taste profile. Nothing in internal/taste may read
|
||||||
|
// this table.
|
||||||
|
// Upsert, so re-snoozing an already-snoozed candidate EXTENDS it instead of
|
||||||
|
// erroring on the PK. The name is refreshed too — a later suggestion may
|
||||||
|
// carry a corrected spelling from the similarity feed.
|
||||||
|
// $1=user_id, $2=candidate_mbid, $3=candidate_name, $4=duration in days.
|
||||||
|
func (q *Queries) SnoozeSuggestion(ctx context.Context, arg SnoozeSuggestionParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, snoozeSuggestion,
|
||||||
|
arg.UserID,
|
||||||
|
arg.CandidateMbid,
|
||||||
|
arg.CandidateName,
|
||||||
|
arg.Column4,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsnoozeSuggestion = `-- name: UnsnoozeSuggestion :execrows
|
||||||
|
DELETE FROM suggestion_snoozes
|
||||||
|
WHERE user_id = $1 AND candidate_mbid = $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type UnsnoozeSuggestionParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
CandidateMbid string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row count is returned so the handler can 404 an MBID that was never
|
||||||
|
// snoozed rather than reporting success for a no-op.
|
||||||
|
func (q *Queries) UnsnoozeSuggestion(ctx context.Context, arg UnsnoozeSuggestionParams) (int64, error) {
|
||||||
|
result, err := q.db.Exec(ctx, unsnoozeSuggestion, arg.UserID, arg.CandidateMbid)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected(), nil
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackR
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTrackByID = `-- name: GetTrackByID :one
|
const getTrackByID = `-- name: GetTrackByID :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version FROM tracks WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
||||||
@@ -114,12 +114,13 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTrackByPath = `-- name: GetTrackByPath :one
|
const getTrackByPath = `-- name: GetTrackByPath :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE file_path = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version FROM tracks WHERE file_path = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
||||||
@@ -143,12 +144,13 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = ANY($1::uuid[])
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version FROM tracks WHERE id = ANY($1::uuid[])
|
||||||
`
|
`
|
||||||
|
|
||||||
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
||||||
@@ -180,6 +182,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -192,7 +195,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -250,6 +253,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -264,7 +268,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version, t.tag_read_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -319,6 +323,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
&i.Track.TagSource,
|
&i.Track.TagSource,
|
||||||
&i.Track.TagSourcesVersion,
|
&i.Track.TagSourcesVersion,
|
||||||
|
&i.Track.TagReadVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -333,7 +338,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version FROM tracks
|
||||||
WHERE album_id = $1
|
WHERE album_id = $1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -377,6 +382,7 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -424,7 +430,7 @@ func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchTracks = `-- name: SearchTracks :many
|
const searchTracks = `-- name: SearchTracks :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version FROM tracks
|
||||||
WHERE title ILIKE '%' || $1::text || '%'
|
WHERE title ILIKE '%' || $1::text || '%'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -475,6 +481,7 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -507,8 +514,9 @@ func (q *Queries) SetTrackMbidIfNull(ctx context.Context, arg SetTrackMbidIfNull
|
|||||||
const upsertTrack = `-- name: UpsertTrack :one
|
const upsertTrack = `-- name: UpsertTrack :one
|
||||||
INSERT INTO tracks (
|
INSERT INTO tracks (
|
||||||
title, album_id, artist_id, track_number, disc_number,
|
title, album_id, artist_id, track_number, disc_number,
|
||||||
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre
|
duration_ms, file_path, file_size, file_format, bitrate, mbid, genre,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
tag_read_version
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||||
ON CONFLICT (file_path) DO UPDATE SET
|
ON CONFLICT (file_path) DO UPDATE SET
|
||||||
title = EXCLUDED.title,
|
title = EXCLUDED.title,
|
||||||
album_id = EXCLUDED.album_id,
|
album_id = EXCLUDED.album_id,
|
||||||
@@ -521,23 +529,27 @@ ON CONFLICT (file_path) DO UPDATE SET
|
|||||||
bitrate = EXCLUDED.bitrate,
|
bitrate = EXCLUDED.bitrate,
|
||||||
mbid = EXCLUDED.mbid,
|
mbid = EXCLUDED.mbid,
|
||||||
genre = EXCLUDED.genre,
|
genre = EXCLUDED.genre,
|
||||||
|
-- Stamped on update too, so a tag-repair pass marks rows as done and the
|
||||||
|
-- next scan can short-circuit them again (#2499).
|
||||||
|
tag_read_version = EXCLUDED.tag_read_version,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version
|
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version, tag_read_version
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertTrackParams struct {
|
type UpsertTrackParams struct {
|
||||||
Title string
|
Title string
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
TrackNumber *int32
|
TrackNumber *int32
|
||||||
DiscNumber *int32
|
DiscNumber *int32
|
||||||
DurationMs int32
|
DurationMs int32
|
||||||
FilePath string
|
FilePath string
|
||||||
FileSize int64
|
FileSize int64
|
||||||
FileFormat string
|
FileFormat string
|
||||||
Bitrate *int32
|
Bitrate *int32
|
||||||
Mbid *string
|
Mbid *string
|
||||||
Genre *string
|
Genre *string
|
||||||
|
TagReadVersion int16
|
||||||
}
|
}
|
||||||
|
|
||||||
// file_path is the canonical identity for library scan; mbid is secondary.
|
// file_path is the canonical identity for library scan; mbid is secondary.
|
||||||
@@ -555,6 +567,7 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
|
|||||||
arg.Bitrate,
|
arg.Bitrate,
|
||||||
arg.Mbid,
|
arg.Mbid,
|
||||||
arg.Genre,
|
arg.Genre,
|
||||||
|
arg.TagReadVersion,
|
||||||
)
|
)
|
||||||
var i Track
|
var i Track
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -575,6 +588,7 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
|
|||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
&i.TagSource,
|
&i.TagSource,
|
||||||
&i.TagSourcesVersion,
|
&i.TagSourcesVersion,
|
||||||
|
&i.TagReadVersion,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS suggestion_snoozes_expiry_idx;
|
||||||
|
DROP TABLE IF EXISTS suggestion_snoozes;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- 0049_suggestion_snoozes.up.sql — time-boxed "not right now" on a Discover
|
||||||
|
-- artist suggestion (#2374, milestone #268 slice 3).
|
||||||
|
--
|
||||||
|
-- This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down /
|
||||||
|
-- exclusion UI, and a snooze deliberately isn't one: it records no verdict on
|
||||||
|
-- the music, expires on its own, and MUST NEVER feed the taste profile. It is
|
||||||
|
-- acquisition triage — "I don't want to request this right now" — so the same
|
||||||
|
-- candidate is free to return once snoozed_until passes. Anything that reads
|
||||||
|
-- this table as negative preference signal is a bug.
|
||||||
|
--
|
||||||
|
-- Per-user (rule #47), never global: one household member parking a
|
||||||
|
-- suggestion must not remove it from anyone else's deck.
|
||||||
|
--
|
||||||
|
-- candidate_mbid is text with NO foreign key, on purpose. Suggestions come
|
||||||
|
-- from artist_similarity_unmatched and are out-of-library BY DEFINITION, so
|
||||||
|
-- there is no artists row to reference — the MBID is the only stable identity
|
||||||
|
-- available. candidate_name is denormalized for the same reason: the manage /
|
||||||
|
-- un-snooze list has nowhere else to resolve a display name from.
|
||||||
|
|
||||||
|
CREATE TABLE suggestion_snoozes (
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
candidate_mbid text NOT NULL,
|
||||||
|
candidate_name text NOT NULL,
|
||||||
|
snoozed_until timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, candidate_mbid)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Supports the gc sweep's unqualified `WHERE snoozed_until < now()` scan. The
|
||||||
|
-- composite PK already covers every per-user read, so this is the only extra
|
||||||
|
-- index worth its write cost at household-scale row counts (same reasoning as
|
||||||
|
-- lidarr_quarantine in 0011).
|
||||||
|
CREATE INDEX suggestion_snoozes_expiry_idx ON suggestion_snoozes (snoozed_until);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS candidate_artist_tag_state_source_idx;
|
||||||
|
DROP TABLE IF EXISTS candidate_artist_tag_state;
|
||||||
|
DROP TABLE IF EXISTS candidate_artist_tags;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- 0050_candidate_artist_tags.up.sql — folksonomy tags for OUT-OF-LIBRARY
|
||||||
|
-- artists (#2376, milestone #268 slice 5).
|
||||||
|
--
|
||||||
|
-- track_tags (0042) cannot hold these: it is FK'd to tracks(id), and a
|
||||||
|
-- Discover candidate has no local row by definition. So this is a parallel
|
||||||
|
-- cache keyed by the candidate's MusicBrainz MBID — the only stable identity
|
||||||
|
-- available for an artist we don't have.
|
||||||
|
--
|
||||||
|
-- Purpose is slice 6: rank suggestions by overlap between these tags and the
|
||||||
|
-- user's taste_profile_tags, turning "neighbour of an artist you play" into
|
||||||
|
-- "matches the sound you like".
|
||||||
|
--
|
||||||
|
-- GLOBAL, not per-user: a candidate's tags are a property of the artist, not
|
||||||
|
-- of anyone's taste. Nothing here is user-scoped, so rule #47 has nothing to
|
||||||
|
-- scope — the per-user part lives entirely in slice 6's ranking.
|
||||||
|
--
|
||||||
|
-- weight is a normalized folksonomy strength in [0,1], same scale as
|
||||||
|
-- track_tags, so the two can be compared without a conversion step.
|
||||||
|
CREATE TABLE candidate_artist_tags (
|
||||||
|
candidate_mbid text NOT NULL,
|
||||||
|
tag text NOT NULL,
|
||||||
|
weight double precision NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (candidate_mbid, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enrichment bookkeeping. This is a SEPARATE table rather than columns on the
|
||||||
|
-- tags table, because the "providers had nothing" outcome must be recordable
|
||||||
|
-- for a candidate with zero tag rows — otherwise every empty candidate stays
|
||||||
|
-- eligible forever and the worker re-fetches it on every tick.
|
||||||
|
--
|
||||||
|
-- tracks solved the same problem with columns on `tracks` (0042), but there is
|
||||||
|
-- no per-candidate row anywhere to hang them off: artist_similarity_unmatched
|
||||||
|
-- is keyed (seed_artist_id, candidate_mbid, source) and holds MANY rows per
|
||||||
|
-- candidate.
|
||||||
|
--
|
||||||
|
-- Absence of a row here means "never processed", so unlike tracks.tag_source
|
||||||
|
-- this column can be NOT NULL — there is no null-means-pending state to model.
|
||||||
|
-- 'musicbrainz' | 'lastfm' | 'mixed' → found, cached
|
||||||
|
-- 'none' → providers confirmed nothing
|
||||||
|
-- tag_sources_version → bump to re-process settled 'none'
|
||||||
|
--
|
||||||
|
-- A transient failure writes NO row at all (rather than a row it would then
|
||||||
|
-- have to distinguish), which leaves the candidate eligible for the next pass.
|
||||||
|
CREATE TABLE candidate_artist_tag_state (
|
||||||
|
candidate_mbid text PRIMARY KEY,
|
||||||
|
tag_source text NOT NULL,
|
||||||
|
tag_sources_version integer NOT NULL DEFAULT 0,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Serves the eligibility scan's "settled 'none' under an older version"
|
||||||
|
-- branch. The PK already covers the per-candidate lookups.
|
||||||
|
CREATE INDEX candidate_artist_tag_state_source_idx
|
||||||
|
ON candidate_artist_tag_state (tag_source, tag_sources_version);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Drop any audit rows under the scope the constraint is about to forbid,
|
||||||
|
-- otherwise re-adding the narrower CHECK fails against existing data.
|
||||||
|
DELETE FROM recommendation_tuning_audit WHERE scope = 'discover';
|
||||||
|
ALTER TABLE recommendation_tuning_audit
|
||||||
|
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
|
||||||
|
ALTER TABLE recommendation_tuning_audit
|
||||||
|
ADD CONSTRAINT recommendation_tuning_audit_scope_check
|
||||||
|
CHECK (scope IN ('radio', 'daily_mix', 'taste'));
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS discover_tuning;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- 0051_discover_tuning.up.sql — tunable knobs for the Discover request
|
||||||
|
-- surface (#2377, milestone #268 slice 6).
|
||||||
|
--
|
||||||
|
-- A FOURTH tuning scope alongside radio / daily_mix / taste. Its own scope
|
||||||
|
-- rather than extra columns on taste_tuning, for a reason that matters:
|
||||||
|
-- snooze_days lives here, and a snooze must never be read as taste signal
|
||||||
|
-- (#2374). Filing it under 'taste' would put it one careless join away from
|
||||||
|
-- exactly the leak that design forbids.
|
||||||
|
--
|
||||||
|
-- Per rule #25 these are DB-backed and editable in the admin UI with no
|
||||||
|
-- restart — the shipped values below are defaults, not settings.
|
||||||
|
CREATE TABLE discover_tuning (
|
||||||
|
singleton boolean PRIMARY KEY DEFAULT true
|
||||||
|
CONSTRAINT discover_tuning_singleton_check CHECK (singleton),
|
||||||
|
-- How strongly taste-tag overlap boosts a candidate's similarity score.
|
||||||
|
-- The blend is MULTIPLICATIVE: score * (1 + w * overlap), overlap in
|
||||||
|
-- [0,1]. So 0 disables the feature outright and leaves pure similarity
|
||||||
|
-- ranking, 1.0 lets a perfectly-matching candidate double its score, and
|
||||||
|
-- a candidate with no cached tags is unchanged rather than penalised
|
||||||
|
-- (rule #131 — tag coverage is permanently partial, see #2376).
|
||||||
|
tag_overlap_weight double precision NOT NULL,
|
||||||
|
-- Default snooze duration in days. Was a Go constant in
|
||||||
|
-- internal/api/suggestions.go; moved here per rule #25.
|
||||||
|
snooze_days double precision NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
|
||||||
|
VALUES (true, 1.0, 90);
|
||||||
|
|
||||||
|
-- Rule #36: a new value for a CHECK-gated column needs the constraint
|
||||||
|
-- rewritten in the SAME change, or the first audit row written under the new
|
||||||
|
-- scope fails at runtime rather than at migrate time.
|
||||||
|
ALTER TABLE recommendation_tuning_audit
|
||||||
|
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
|
||||||
|
ALTER TABLE recommendation_tuning_audit
|
||||||
|
ADD CONSTRAINT recommendation_tuning_audit_scope_check
|
||||||
|
CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover'));
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE sessions
|
||||||
|
DROP COLUMN created_ip,
|
||||||
|
DROP COLUMN last_ip;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Session provenance for the active-sessions surface (#370).
|
||||||
|
--
|
||||||
|
-- TWO addresses, not one, and the pair is the point: a session created at
|
||||||
|
-- home and now being used from somewhere else is the shape of a stolen
|
||||||
|
-- token. A single "current IP" column can't express that, and a single
|
||||||
|
-- "origin IP" column goes stale the moment the token moves.
|
||||||
|
--
|
||||||
|
-- text rather than inet, matching user_agent directly above: these are
|
||||||
|
-- stored to be displayed, never queried by subnet, and inet round-trips
|
||||||
|
-- through pgx/sqlc as a netip.Prefix that renders as "1.2.3.4/32" and would
|
||||||
|
-- need unwrapping at every display site.
|
||||||
|
--
|
||||||
|
-- DEFAULT '' rather than NULL so existing rows — and any future insert that
|
||||||
|
-- genuinely can't determine an address — stay renderable without a null
|
||||||
|
-- check at every call site. The UI reads empty as "unknown" rather than
|
||||||
|
-- inventing a value.
|
||||||
|
ALTER TABLE sessions
|
||||||
|
ADD COLUMN created_ip text NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN last_ip text NOT NULL DEFAULT '';
|
||||||