Compare commits
130
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
324059b2bd | ||
|
|
eec59193fa | ||
|
|
ca4832e620 | ||
|
|
cf0d37bf8e | ||
|
|
799dab029a | ||
|
|
7315e37c15 | ||
|
|
4f9b083eec | ||
|
|
f17356560d | ||
|
|
18a61f1065 | ||
|
|
6e39471a70 | ||
|
|
86af79bd2f | ||
|
|
e006de5d4b | ||
|
|
94e2cac03b | ||
|
|
b27029f674 | ||
|
|
14aa22198f | ||
|
|
1138d75a45 | ||
|
|
cf7b489fec | ||
|
|
8483948f23 | ||
|
|
0cea82984c | ||
|
|
3acac985cd | ||
|
|
d3b40342b4 | ||
|
|
4f99b42844 | ||
|
|
5044e7a055 | ||
|
|
7d45a4e5c7 | ||
|
|
fa0827f668 | ||
|
|
52d53e0044 | ||
|
|
a26ef4e93c | ||
|
|
0774f5f55f | ||
|
|
509cbe79b2 | ||
|
|
dc7b9b78fa | ||
|
|
cde74b5965 | ||
|
|
0efbf5fcaa | ||
|
|
2038028d42 | ||
|
|
723293110d | ||
|
|
41ebf1405b | ||
|
|
f2dcf2596d | ||
|
|
47de7be472 | ||
|
|
659554df0e | ||
|
|
2ecdd46a2b | ||
|
|
41fe76b90c | ||
|
|
6912dadf2b | ||
|
|
304e06acc8 | ||
|
|
235839b696 | ||
|
|
4f69c230c4 | ||
|
|
5749f48b4a | ||
|
|
f0c08e7326 | ||
|
|
199fec2058 | ||
|
|
65dd132b3d | ||
|
|
40384cc05e | ||
|
|
40056d2e9a | ||
|
|
2b3be8311a | ||
|
|
c30511e71b | ||
|
|
725ddca950 | ||
|
|
d145fee35d | ||
|
|
611715154b | ||
|
|
23a82fb38d | ||
|
|
0de2437689 | ||
|
|
a251dce7e3 | ||
|
|
938dae7163 | ||
|
|
93365cb555 | ||
|
|
eeabdf1f2c | ||
|
|
3e258507bb | ||
|
|
9550d8daaf | ||
|
|
db393bbe65 | ||
|
|
7b7bd0c3e8 | ||
|
|
8019537b02 | ||
|
|
e41d603c12 | ||
|
|
a62f07bd3a | ||
|
|
3c646c6974 | ||
|
|
9a57dc4bec | ||
|
|
f167ddfbfb | ||
|
|
962b4dbc8c | ||
|
|
aa4089118e | ||
|
|
7c791dc8e4 | ||
|
|
11466e1525 | ||
|
|
301c3bfb86 | ||
|
|
4d8c7d6566 | ||
|
|
d6e6caa223 | ||
|
|
8b08482d13 | ||
|
|
7cf04fe24b | ||
|
|
222a0ff636 | ||
|
|
d75c1ae37f | ||
|
|
3c4c27fb08 | ||
|
|
a62a20b599 | ||
|
|
d9b2dd957c | ||
|
|
46dcd38fd8 | ||
|
|
7838038047 | ||
|
|
b64965b38d | ||
|
|
bf2f9f3811 | ||
|
|
deb726a285 | ||
|
|
7ede83a586 | ||
|
|
1d7b91333f | ||
|
|
883d416d26 | ||
|
|
09471a8f5c | ||
|
|
7de238e91e | ||
|
|
9440c5860b | ||
|
|
082d31bfa9 | ||
|
|
8847b43d9e | ||
|
|
09cc810e5a | ||
|
|
2534384ed1 | ||
|
|
9ff1b30e3f | ||
|
|
c01853577b | ||
|
|
747ed4134b | ||
|
|
ccbd3b62a0 | ||
|
|
19de0c2874 | ||
|
|
22a4649bfc | ||
|
|
8e7660c05e | ||
|
|
53be834e89 | ||
|
|
0d410630a2 | ||
|
|
62db8edcdb | ||
|
|
ec0cc37bc9 | ||
|
|
e772938a3b | ||
|
|
29fee5aa37 | ||
|
|
e5ab471ce1 | ||
|
|
573aa4226d | ||
|
|
7339815ea9 | ||
|
|
baa601765e | ||
|
|
0d009b34e2 | ||
|
|
f1b4652c77 | ||
|
|
e610948307 | ||
|
|
fb811804d2 | ||
|
|
37134950a5 | ||
|
|
fcded9294c | ||
|
|
42abb7adff | ||
|
|
04933f2d9f | ||
|
|
626fc7502c | ||
|
|
9bf3b8a2f2 | ||
|
|
492460cf4a | ||
|
|
1c775905d7 | ||
|
|
cd2ff648d0 |
@@ -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 ./...
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
|
||||||
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-through accessor for the tag-enrichment provider settings (#1521).
|
||||||
|
* No Room caching — admin settings are infrequent point-and-shoot edits;
|
||||||
|
* mutations fire direct REST and the ViewModel reconciles on failure.
|
||||||
|
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AdminTagSourcesRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: AdminTagSourcesApi = retrofit.create()
|
||||||
|
|
||||||
|
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
|
||||||
|
|
||||||
|
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
|
||||||
|
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
|
||||||
|
|
||||||
|
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
|
||||||
|
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
|
||||||
|
|
||||||
|
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
|
||||||
|
id = id,
|
||||||
|
displayName = displayName,
|
||||||
|
requiresApiKey = requiresApiKey,
|
||||||
|
supports = supports,
|
||||||
|
enabled = enabled,
|
||||||
|
apiKeySet = apiKeySet,
|
||||||
|
testable = testable,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
|
||||||
|
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
|
||||||
@@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.composables.icons.lucide.Inbox
|
import com.composables.icons.lucide.Inbox
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Music
|
||||||
import com.composables.icons.lucide.TriangleAlert
|
import com.composables.icons.lucide.TriangleAlert
|
||||||
import com.composables.icons.lucide.Users
|
import com.composables.icons.lucide.Users
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
|
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.nav.Admin
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
|
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||||
import com.fabledsword.minstrel.nav.AdminUsers
|
import com.fabledsword.minstrel.nav.AdminUsers
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -54,7 +57,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
|
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
|
||||||
|
|
||||||
sealed interface AdminLandingUiState {
|
sealed interface AdminLandingUiState {
|
||||||
data object Loading : AdminLandingUiState
|
data object Loading : AdminLandingUiState
|
||||||
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
private val requestsRepo: AdminRequestsRepository,
|
private val requestsRepo: AdminRequestsRepository,
|
||||||
private val quarantineRepo: AdminQuarantineRepository,
|
private val quarantineRepo: AdminQuarantineRepository,
|
||||||
private val usersRepo: AdminUsersRepository,
|
private val usersRepo: AdminUsersRepository,
|
||||||
|
private val tagSourcesRepo: AdminTagSourcesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||||
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
val req = async { requestsRepo.list().size }
|
val req = async { requestsRepo.list().size }
|
||||||
val qua = async { quarantineRepo.list().size }
|
val qua = async { quarantineRepo.list().size }
|
||||||
val usr = async { usersRepo.list().size }
|
val usr = async { usersRepo.list().size }
|
||||||
AdminCounts(req.await(), qua.await(), usr.await())
|
val tag = async { tagSourcesRepo.list().count { it.enabled } }
|
||||||
|
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
|
||||||
}
|
}
|
||||||
internal.value = AdminLandingUiState.Success(counts)
|
internal.value = AdminLandingUiState.Success(counts)
|
||||||
} catch (
|
} catch (
|
||||||
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
|||||||
onClick = { navController.navigate(AdminUsers) },
|
onClick = { navController.navigate(AdminUsers) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
SectionCard(
|
||||||
|
icon = Lucide.Music,
|
||||||
|
title = "Tag sources",
|
||||||
|
subtitle = "Metadata enrichment providers",
|
||||||
|
count = counts.tagSources,
|
||||||
|
onClick = { navController.navigate(AdminTagSources) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
|
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AdminTagSourcesScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
MinstrelTopAppBar(
|
||||||
|
title = "Admin · Tag sources",
|
||||||
|
navController = navController,
|
||||||
|
currentRouteName = AdminTagSources::class.qualifiedName,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
PullToRefreshScaffold(
|
||||||
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
AdminTagSourcesUiState.Loading -> LoadingCentered()
|
||||||
|
AdminTagSourcesUiState.Empty -> EmptyState(
|
||||||
|
title = "No tag sources",
|
||||||
|
body = "Tag-enrichment providers register on the server; none are available.",
|
||||||
|
)
|
||||||
|
is AdminTagSourcesUiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load tag sources",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
|
)
|
||||||
|
is AdminTagSourcesUiState.Success -> TagSourceList(
|
||||||
|
providers = s.providers,
|
||||||
|
testResults = s.testResults,
|
||||||
|
onToggle = viewModel::setEnabled,
|
||||||
|
onSaveKey = viewModel::saveApiKey,
|
||||||
|
onTest = viewModel::test,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TagSourceList(
|
||||||
|
providers: List<AdminTagSourceRef>,
|
||||||
|
testResults: Map<String, TagSourceTestResult>,
|
||||||
|
onToggle: (String, Boolean) -> Unit,
|
||||||
|
onSaveKey: (String, String) -> Unit,
|
||||||
|
onTest: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(items = providers, key = { it.id }) { provider ->
|
||||||
|
TagSourceCard(
|
||||||
|
provider = provider,
|
||||||
|
testResult = testResults[provider.id],
|
||||||
|
onToggle = onToggle,
|
||||||
|
onSaveKey = onSaveKey,
|
||||||
|
onTest = onTest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TagSourceCard(
|
||||||
|
provider: AdminTagSourceRef,
|
||||||
|
testResult: TagSourceTestResult?,
|
||||||
|
onToggle: (String, Boolean) -> Unit,
|
||||||
|
onSaveKey: (String, String) -> Unit,
|
||||||
|
onTest: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = provider.enabled,
|
||||||
|
onCheckedChange = { onToggle(provider.id, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (provider.requiresApiKey) {
|
||||||
|
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
|
||||||
|
}
|
||||||
|
if (provider.testable) {
|
||||||
|
TestRow(provider = provider, testResult = testResult, onTest = onTest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
|
||||||
|
var key by remember(provider.id) { mutableStateOf("") }
|
||||||
|
OutlinedTextField(
|
||||||
|
value = key,
|
||||||
|
onValueChange = { key = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("API key") },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
if (provider.apiKeySet) {
|
||||||
|
"••• saved — leave blank to keep"
|
||||||
|
} else {
|
||||||
|
"Paste your API key to enable this source"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSaveKey(provider.id, key)
|
||||||
|
key = ""
|
||||||
|
},
|
||||||
|
enabled = key.isNotBlank(),
|
||||||
|
) { Text("Save key") }
|
||||||
|
if (provider.apiKeySet) {
|
||||||
|
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TestRow(
|
||||||
|
provider: AdminTagSourceRef,
|
||||||
|
testResult: TagSourceTestResult?,
|
||||||
|
onTest: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
|
||||||
|
testResult?.let { result ->
|
||||||
|
if (result.ok) {
|
||||||
|
Text(
|
||||||
|
text = "OK (${result.durationMs}ms)",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Failed — ${result.error}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
sealed interface AdminTagSourcesUiState {
|
||||||
|
data object Loading : AdminTagSourcesUiState
|
||||||
|
data object Empty : AdminTagSourcesUiState
|
||||||
|
data class Success(
|
||||||
|
val providers: List<AdminTagSourceRef>,
|
||||||
|
val testResults: Map<String, TagSourceTestResult>,
|
||||||
|
) : AdminTagSourcesUiState
|
||||||
|
data class Error(val message: String) : AdminTagSourcesUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AdminTagSourcesViewModel @Inject constructor(
|
||||||
|
private val repository: AdminTagSourcesRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow<AdminTagSourcesUiState>(AdminTagSourcesUiState.Loading)
|
||||||
|
val uiState: StateFlow<AdminTagSourcesUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
// Screen-level auto-recovery: reload a failed list when server
|
||||||
|
// health returns instead of waiting for a manual pull (issue #1245).
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminTagSourcesUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
internal.value = AdminTagSourcesUiState.Loading
|
||||||
|
try {
|
||||||
|
val providers = repository.list()
|
||||||
|
internal.value = if (providers.isEmpty()) {
|
||||||
|
AdminTagSourcesUiState.Empty
|
||||||
|
} else {
|
||||||
|
AdminTagSourcesUiState.Success(providers, emptyMap())
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(id: String, enabled: Boolean) {
|
||||||
|
val before = internal.value as? AdminTagSourcesUiState.Success ?: return
|
||||||
|
// Optimistic toggle; reconcile via refresh() if the server rejects.
|
||||||
|
internal.value = before.copy(
|
||||||
|
providers = before.providers.map {
|
||||||
|
if (it.id == id) it.copy(enabled = enabled) else it
|
||||||
|
},
|
||||||
|
)
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.setEnabled(id, enabled)
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveApiKey(id: String, apiKey: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
replaceProvider(repository.setApiKey(id, apiKey))
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun test(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = try {
|
||||||
|
repository.test(id)
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch
|
||||||
|
internal.value = current.copy(testResults = current.testResults + (id to result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceProvider(updated: AdminTagSourceRef) {
|
||||||
|
val current = internal.value as? AdminTagSourcesUiState.Success ?: return
|
||||||
|
internal.value = current.copy(
|
||||||
|
providers = current.providers.map { if (it.id == updated.id) updated else it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.PATCH
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
|
||||||
|
* surface for the tag-enrichment provider settings (#1490). Mirrors the
|
||||||
|
* web integrations "Tag enrichment sources" card. Same shape as the
|
||||||
|
* cover-sources admin surface; kept independent so a new tag source is
|
||||||
|
* added without touching art settings.
|
||||||
|
*/
|
||||||
|
interface AdminTagSourcesApi {
|
||||||
|
@GET("api/admin/tag-sources")
|
||||||
|
suspend fun list(): AdminTagSourcesListWire
|
||||||
|
|
||||||
|
@PATCH("api/admin/tag-sources/{id}")
|
||||||
|
suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire
|
||||||
|
|
||||||
|
@POST("api/admin/tag-sources/{id}/test")
|
||||||
|
suspend fun test(@Path("id") id: String): TestTagSourceWire
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH body. Both fields are nullable + default-null so kotlinx omits the
|
||||||
|
* untouched one (the server reads a missing/null field as "leave
|
||||||
|
* unchanged"): send only `enabled` to toggle, only `apiKey` to set a key.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class UpdateTagSourceBody(
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
@SerialName("api_key") val apiKey: String? = null,
|
||||||
|
)
|
||||||
@@ -3,9 +3,13 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,4 +34,30 @@ interface DiscoverApi {
|
|||||||
|
|
||||||
@POST("api/requests")
|
@POST("api/requests")
|
||||||
suspend fun createRequest(@Body body: CreateRequestBody)
|
suspend fun createRequest(@Body body: CreateRequestBody)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parks a suggestion — "not right now", NOT a dislike. Time-boxed
|
||||||
|
* server-side (90 days) and never fed into the taste profile.
|
||||||
|
*
|
||||||
|
* [body] must carry the artist's name: candidates are out-of-library, so
|
||||||
|
* the server has no local row to resolve a display name from and returns
|
||||||
|
* 400 without it.
|
||||||
|
*/
|
||||||
|
@POST("api/discover/suggestions/{mbid}/snooze")
|
||||||
|
suspend fun snoozeSuggestion(
|
||||||
|
@Path("mbid") mbid: String,
|
||||||
|
@Body body: SnoozeSuggestionBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Brings a parked suggestion back. 404 when it wasn't snoozed. */
|
||||||
|
@DELETE("api/discover/suggestions/{mbid}/snooze")
|
||||||
|
suspend fun unsnoozeSuggestion(@Path("mbid") mbid: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently-parked suggestions. Server filters expired rows, so every
|
||||||
|
* row returned is still snoozed. This is the only route back to an
|
||||||
|
* un-snooze once the card has left the deck.
|
||||||
|
*/
|
||||||
|
@GET("api/discover/snoozes")
|
||||||
|
suspend fun listSnoozes(): List<SuggestionSnoozeWire>
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-3
@@ -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()
|
||||||
|
|||||||
+25
-1
@@ -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)",
|
||||||
|
|||||||
+43
@@ -35,6 +35,12 @@ object MutationKind {
|
|||||||
// background avoids the duplicate + orphan row the old offline-on-stop
|
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||||
// path produced (see 2026-06-11 contract audit).
|
// path produced (see 2026-06-11 contract audit).
|
||||||
const val PLAY_ENDED: String = "play_ended"
|
const val PLAY_ENDED: String = "play_ended"
|
||||||
|
|
||||||
|
// #2374 suggestion snooze. ONE toggle kind rather than separate
|
||||||
|
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
|
||||||
|
// an undo collapses to the latest intent instead of replaying as two
|
||||||
|
// opposed calls whose order decides the outcome.
|
||||||
|
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,6 +158,25 @@ class MutationQueue @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a suggestion snooze (or its undo) for replay. [desiredSnoozed]
|
||||||
|
* is the TARGET state, so repeated taps collapse to one replay.
|
||||||
|
*
|
||||||
|
* [name] is carried even for an un-snooze, where the server ignores it,
|
||||||
|
* so a single payload shape serves both directions.
|
||||||
|
*/
|
||||||
|
suspend fun enqueueSuggestionSnoozeToggle(
|
||||||
|
mbid: String,
|
||||||
|
name: String,
|
||||||
|
desiredSnoozed: Boolean,
|
||||||
|
): Long = insertUserDriven(
|
||||||
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||||
|
json.encodeToString(
|
||||||
|
SuggestionSnoozeTogglePayload.serializer(),
|
||||||
|
SuggestionSnoozeTogglePayload(mbid, name, desiredSnoozed),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||||
MutationKind.REQUEST_CANCEL,
|
MutationKind.REQUEST_CANCEL,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
@@ -192,6 +217,21 @@ class MutationQueue @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
|
||||||
|
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
|
||||||
|
* the replayer can collapse repeated toggles for one candidate down to the
|
||||||
|
* last intent. Both directions are idempotent server-side: re-snoozing
|
||||||
|
* extends the window, and un-snoozing something already back is a 404 the
|
||||||
|
* replayer treats as permanent (nothing left to do).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SuggestionSnoozeTogglePayload(
|
||||||
|
val mbid: String,
|
||||||
|
val name: String,
|
||||||
|
val desiredSnoozed: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
||||||
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
||||||
@@ -239,6 +279,9 @@ data class PlayOfflinePayload(
|
|||||||
val atIso: String,
|
val atIso: String,
|
||||||
val durationPlayedMs: Long,
|
val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning; null on payloads queued
|
||||||
|
// before this field existed (decodes to null → server stores NULL).
|
||||||
|
val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+70
-24
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||||
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
@@ -114,11 +115,12 @@ class MutationReplayer @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun drain() {
|
private suspend fun drain() {
|
||||||
val rows = dao.getAll()
|
val rows = dao.getAll()
|
||||||
// Collapse superseded like-toggles: only the latest desired state per
|
// Collapse superseded toggles (likes, suggestion snoozes): only the
|
||||||
// (entity) is replayed; older toggles for the same entity are dropped
|
// latest desired state per entity is replayed; older toggles for the
|
||||||
// unsent. Without this, partial-failure + differential retry could
|
// same entity are dropped unsent. Without this, partial-failure +
|
||||||
// replay an older toggle last and invert the final like state.
|
// differential retry could replay an older toggle last and invert the
|
||||||
val superseded = supersededLikeToggleIds(rows)
|
// final state — a snooze the user already undid would come back.
|
||||||
|
val superseded = supersededToggleIds(rows, json)
|
||||||
for (row in rows) {
|
for (row in rows) {
|
||||||
if (row.id in superseded) {
|
if (row.id in superseded) {
|
||||||
dao.delete(row.id)
|
dao.delete(row.id)
|
||||||
@@ -131,25 +133,6 @@ class MutationReplayer @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
|
|
||||||
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
|
|
||||||
val latestByEntity = HashMap<String, Long>()
|
|
||||||
val superseded = HashSet<Long>()
|
|
||||||
rows.asSequence()
|
|
||||||
.filter { it.kind == MutationKind.LIKE_TOGGLE }
|
|
||||||
.forEach { row ->
|
|
||||||
val decoded = runCatching {
|
|
||||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
|
||||||
}.getOrNull()
|
|
||||||
if (decoded != null) {
|
|
||||||
val key = "${decoded.entityType}:${decoded.entityId}"
|
|
||||||
// `rows` is ascending by id, so a prior entry is always older.
|
|
||||||
latestByEntity.put(key, row.id)?.let(superseded::add)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return superseded
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
||||||
dispatch(row)
|
dispatch(row)
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
@@ -182,6 +165,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
else -> Outcome.DROP
|
else -> Outcome.DROP
|
||||||
}
|
}
|
||||||
@@ -254,6 +238,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
at = decoded.atIso,
|
at = decoded.atIso,
|
||||||
durationPlayedMs = decoded.durationPlayedMs,
|
durationPlayedMs = decoded.durationPlayedMs,
|
||||||
source = decoded.source,
|
source = decoded.source,
|
||||||
|
deviceClass = decoded.deviceClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
@@ -276,6 +261,24 @@ class MutationReplayer @Inject constructor(
|
|||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replays a suggestion snooze in whichever direction the payload asks for.
|
||||||
|
*
|
||||||
|
* The un-snooze branch can legitimately 404 (the row already lapsed, or a
|
||||||
|
* previous attempt landed and the response was lost). [outcomeFor] classes
|
||||||
|
* 404 as permanent → DROP, which is right: the user's intended end state
|
||||||
|
* already holds, so there is nothing left to send.
|
||||||
|
*/
|
||||||
|
private suspend fun dispatchSuggestionSnoozeToggle(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), payload)
|
||||||
|
if (decoded.desiredSnoozed) {
|
||||||
|
discoverApi.snoozeSuggestion(decoded.mbid, SnoozeSuggestionBody(name = decoded.name))
|
||||||
|
} else {
|
||||||
|
discoverApi.unsnoozeSuggestion(decoded.mbid)
|
||||||
|
}
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
@@ -296,3 +299,46 @@ class MutationReplayer @Inject constructor(
|
|||||||
const val HTTP_TOO_MANY = 429
|
const val HTTP_TOO_MANY = 429
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||||
|
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||||
|
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
||||||
|
* replaying a stale one last would invert the final state.
|
||||||
|
*
|
||||||
|
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||||
|
* instance. [rows] must be ascending by id (FIFO), which is what
|
||||||
|
* `CachedMutationDao.getAll()` returns.
|
||||||
|
*/
|
||||||
|
internal fun supersededToggleIds(rows: List<CachedMutationEntity>, json: Json): Set<Long> {
|
||||||
|
val latestByEntity = HashMap<String, Long>()
|
||||||
|
val superseded = HashSet<Long>()
|
||||||
|
rows.asSequence()
|
||||||
|
.mapNotNull { row -> toggleKeyOf(row, json)?.let { key -> key to row.id } }
|
||||||
|
.forEach { (key, id) ->
|
||||||
|
// Ascending ids mean a prior entry for this key is always older.
|
||||||
|
latestByEntity.put(key, id)?.let(superseded::add)
|
||||||
|
}
|
||||||
|
return superseded
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse key for a toggle row, or null when the row isn't a toggle — or its
|
||||||
|
* payload won't decode. Undecodable rows are deliberately left alone rather
|
||||||
|
* than grouped under a shared "corrupt" key, so one bad row can't suppress a
|
||||||
|
* good one behind it; the dispatcher DROPs it on its own.
|
||||||
|
*
|
||||||
|
* The kind is part of the key so two toggle kinds can never collide on the
|
||||||
|
* same entity id.
|
||||||
|
*/
|
||||||
|
private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (row.kind) {
|
||||||
|
MutationKind.LIKE_TOGGLE -> runCatching {
|
||||||
|
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { "${row.kind}:${it.entityType}:${it.entityId}" }
|
||||||
|
|
||||||
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> runCatching {
|
||||||
|
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|||||||
+81
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
|
||||||
|
data class AdminTagSourceRef(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val requiresApiKey: Boolean,
|
||||||
|
val supports: List<String>,
|
||||||
|
val enabled: Boolean,
|
||||||
|
val apiKeySet: Boolean,
|
||||||
|
val testable: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Result of a provider "test connection" call. */
|
||||||
|
data class TagSourceTestResult(
|
||||||
|
val ok: Boolean,
|
||||||
|
val durationMs: Long = 0,
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.fabledsword.minstrel.models
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kind of Lidarr request being created. Wire form is the lowercase
|
* Kind of Lidarr request being created. Wire form is the lowercase
|
||||||
* enum name; the helper [wire] keeps that mapping in one place.
|
* enum name; the helper [wire] keeps that mapping in one place.
|
||||||
@@ -49,6 +52,8 @@ data class ArtistSuggestionRef(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val imageUrl: String = "",
|
val imageUrl: String = "",
|
||||||
val attribution: List<SeedContributionRef> = emptyList(),
|
val attribution: List<SeedContributionRef> = emptyList(),
|
||||||
|
/** Taste-profile tags this candidate matches, strongest first (#2377). */
|
||||||
|
val matchedTags: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val attributionText: String
|
val attributionText: String
|
||||||
get() {
|
get() {
|
||||||
@@ -63,7 +68,92 @@ data class ArtistSuggestionRef(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subtitle line for the card.
|
||||||
|
*
|
||||||
|
* Prefers the taste-tag reason over seed attribution when we have one,
|
||||||
|
* because it describes the MUSIC ("sounds like what you like") rather than
|
||||||
|
* the graph ("adjacent to something you played") — the whole point of
|
||||||
|
* milestone #268 slice 6. Falls back to attribution, which is the common
|
||||||
|
* case: tag coverage for out-of-library artists is partial by nature
|
||||||
|
* (#2376), so most candidates have no matched tags.
|
||||||
|
*
|
||||||
|
* Kept in lockstep with the web client's reasonText() in
|
||||||
|
* SuggestionFeed.svelte — same wording, same Oxford comma.
|
||||||
|
*/
|
||||||
|
val reasonText: String
|
||||||
|
get() {
|
||||||
|
val tags = matchedTags.take(MAX_ATTRIBUTION_PHRASES)
|
||||||
|
return when (tags.size) {
|
||||||
|
0 -> attributionText
|
||||||
|
1 -> "Matches your taste in ${tags[0]}."
|
||||||
|
2 -> "Matches your taste in ${tags[0]} and ${tags[1]}."
|
||||||
|
else -> "Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_ATTRIBUTION_PHRASES = 3
|
private const val MAX_ATTRIBUTION_PHRASES = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A suggestion the user parked with "not right now" (#2374).
|
||||||
|
*
|
||||||
|
* Deliberately NOT a dislike: it carries no verdict on the artist, expires on
|
||||||
|
* its own, and never reaches the taste profile. Anything that treats this as
|
||||||
|
* negative preference signal is a bug.
|
||||||
|
*
|
||||||
|
* [snoozedUntil] is the raw RFC3339 string from the wire. Only the server
|
||||||
|
* decides whether a snooze is still in effect — every row the client receives
|
||||||
|
* already is — so this is read purely to phrase "back in about 3 months".
|
||||||
|
*/
|
||||||
|
data class SuggestionSnoozeRef(
|
||||||
|
val mbid: String,
|
||||||
|
val name: String,
|
||||||
|
val snoozedUntil: String,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Relative return phrase for the manage list. Relative rather than a
|
||||||
|
* calendar date because the exact day a 90-day snooze lapses is noise the
|
||||||
|
* user never asked for.
|
||||||
|
*
|
||||||
|
* [nowMs] is injectable so this is testable without freezing the clock.
|
||||||
|
* Returns "shortly" for an unparseable or already-past timestamp: the row
|
||||||
|
* is on screen, so the server still considers it snoozed, and guessing is
|
||||||
|
* better than rendering an empty line.
|
||||||
|
*/
|
||||||
|
fun returnsIn(nowMs: Long = System.currentTimeMillis()): String {
|
||||||
|
val remainingMs = runCatching { Instant.parse(snoozedUntil).toEpochMilliseconds() }
|
||||||
|
.getOrNull()?.minus(nowMs)
|
||||||
|
// Two ways to have nothing to state: an unparseable timestamp, or one
|
||||||
|
// already lapsed by our clock though the server still returned the row
|
||||||
|
// (the two disagree). Neither is "today", which would read as a real
|
||||||
|
// prediction.
|
||||||
|
if (remainingMs == null || remainingMs <= 0) return "shortly"
|
||||||
|
val days = (remainingMs.toDouble() / MILLIS_PER_DAY).roundToInt()
|
||||||
|
return when {
|
||||||
|
days < 1 -> "today"
|
||||||
|
days == 1 -> "tomorrow"
|
||||||
|
days < DAYS_BEFORE_MONTHS -> "in $days days"
|
||||||
|
else -> {
|
||||||
|
val months = (days.toDouble() / DAYS_PER_MONTH).roundToInt()
|
||||||
|
if (months == 1) "in about a month" else "in about $months months"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MILLIS_PER_DAY = 86_400_000.0
|
||||||
|
|
||||||
|
// Below this, days read more naturally than a rounded month count.
|
||||||
|
//
|
||||||
|
// Must be <= DAYS_PER_MONTH, or the singular "in about a month" is
|
||||||
|
// unreachable: a rounded month count of 1 needs 15..44 days, and any
|
||||||
|
// threshold above 30 sends all of those down the days branch instead.
|
||||||
|
// This was 45 and the singular branch was dead code — the unit test
|
||||||
|
// for it is what surfaced that.
|
||||||
|
const val DAYS_BEFORE_MONTHS = 30
|
||||||
|
const val DAYS_PER_MONTH = 30.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
|
||||||
|
* Every field defaults defensively so a missing JSON key never crashes
|
||||||
|
* deserialization. `versionBumped` is present only on the PATCH response.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AdminTagSourceWire(
|
||||||
|
val id: String = "",
|
||||||
|
@SerialName("display_name") val displayName: String = "",
|
||||||
|
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
|
||||||
|
val supports: List<String> = emptyList(),
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
@SerialName("api_key_set") val apiKeySet: Boolean = false,
|
||||||
|
@SerialName("display_order") val displayOrder: Int = 0,
|
||||||
|
val testable: Boolean = false,
|
||||||
|
@SerialName("version_bumped") val versionBumped: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminTagSourcesListWire(
|
||||||
|
val providers: List<AdminTagSourceWire> = emptyList(),
|
||||||
|
@SerialName("sources_version") val sourcesVersion: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TestTagSourceWire(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
@SerialName("duration_ms") val durationMs: Long = 0,
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
@@ -35,6 +35,13 @@ data class ArtistSuggestionWire(
|
|||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("image_url") val imageUrl: String = "",
|
@SerialName("image_url") val imageUrl: String = "",
|
||||||
val attribution: List<SeedContributionWire> = emptyList(),
|
val attribution: List<SeedContributionWire> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Tags this candidate shares with the user's taste profile, strongest
|
||||||
|
* first (max 3, #2377). Absent for most candidates — tag coverage for
|
||||||
|
* out-of-library artists is partial by nature (#2376) — so the default
|
||||||
|
* empty list is the common case, not an error.
|
||||||
|
*/
|
||||||
|
@SerialName("matched_tags") val matchedTags: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +55,36 @@ data class SeedContributionWire(
|
|||||||
@SerialName("is_liked") val isLiked: Boolean = false,
|
@SerialName("is_liked") val isLiked: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /api/discover/snoozes` — a suggestion the user parked
|
||||||
|
* with "not right now". The server only returns rows that are still in
|
||||||
|
* effect, so the client never compares [snoozedUntil] against the clock to
|
||||||
|
* decide whether to show it; it reads it only to say when the artist comes
|
||||||
|
* back.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SuggestionSnoozeWire(
|
||||||
|
val mbid: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
@SerialName("snoozed_until") val snoozedUntil: String = "",
|
||||||
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `POST /api/discover/suggestions/{mbid}/snooze`.
|
||||||
|
*
|
||||||
|
* [name] is required by the server, not decorative: suggestions are
|
||||||
|
* out-of-library, so there is no artists row to resolve a display name from
|
||||||
|
* and the snooze list would have nothing to render. Omitting it is a 400.
|
||||||
|
*
|
||||||
|
* No `days` field. The duration is the server's to own (90 days); pinning it
|
||||||
|
* client-side would freeze the default at whatever this build shipped.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SnoozeSuggestionBody(
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
||||||
* payload shape. Optional fields are emitted only when non-null
|
* payload shape. Optional fields are emitted only when non-null
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
|
|||||||
@SerialName("track_id") val trackId: String,
|
@SerialName("track_id") val trackId: String,
|
||||||
@SerialName("client_id") val clientId: String,
|
@SerialName("client_id") val clientId: String,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning (server normalizes).
|
||||||
|
@SerialName("device_class") val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
|
|||||||
val at: String,
|
val at: String,
|
||||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning (server normalizes).
|
||||||
|
@SerialName("device_class") val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
|
|||||||
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
||||||
|
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
||||||
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
composable<AdminTagSources> {
|
||||||
|
WithAnimatedScope {
|
||||||
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
|
AdminTagSourcesScreen(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable data object AdminRequests
|
@Serializable data object AdminRequests
|
||||||
@Serializable data object AdminQuarantine
|
@Serializable data object AdminQuarantine
|
||||||
@Serializable data object AdminUsers
|
@Serializable data object AdminUsers
|
||||||
|
@Serializable data object AdminTagSources
|
||||||
|
|
||||||
// ── Outside-shell full-screen routes ──────────────────────────────────
|
// ── Outside-shell full-screen routes ──────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+106
-24
@@ -17,6 +17,7 @@ import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -58,8 +59,10 @@ import timber.log.Timber
|
|||||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||||
* advance natively. PollLoop syncs the local cursor by comparing the
|
* advance natively. PollLoop keeps the local cursor aligned to the
|
||||||
* 1-based Track index from GetPositionInfo.
|
* track the renderer is actually playing, matched by track identity
|
||||||
|
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
|
||||||
|
* index drift -- see [syncLocalCursorToRemote].
|
||||||
*/
|
*/
|
||||||
class MinstrelForwardingPlayer(
|
class MinstrelForwardingPlayer(
|
||||||
private val delegate: Player,
|
private val delegate: Player,
|
||||||
@@ -339,6 +342,23 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The system media notification / lock-screen / Android Auto / Wear 'next'
|
||||||
|
// and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
|
||||||
|
// -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
|
||||||
|
// *MediaItem variants the in-app transport buttons call. Un-overridden, the
|
||||||
|
// base ForwardingPlayer forwards these to the paused local delegate -- so
|
||||||
|
// the notification next/prev only nudged the local cursor (which the poll
|
||||||
|
// then re-synced back to Sonos), reading as dead buttons while casting.
|
||||||
|
// Route them through the same Sonos path as the media-item variants when a
|
||||||
|
// UPnP route is engaged; plain local playback keeps the default behaviour.
|
||||||
|
override fun seekToNext() {
|
||||||
|
if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekToPrevious() {
|
||||||
|
if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
override fun getCurrentPosition(): Long =
|
override fun getCurrentPosition(): Long =
|
||||||
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
||||||
|
|
||||||
@@ -526,18 +546,8 @@ class MinstrelForwardingPlayer(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One poll tick: read position + transport state from Sonos, apply to
|
* One poll tick: read position + transport state from Sonos, apply to
|
||||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
* [remoteState], and sync the local cursor to the track the renderer is
|
||||||
* index when not in queue load.
|
* actually playing (see [syncLocalCursorToRemote]).
|
||||||
*
|
|
||||||
* Cursor sync is gated on `holder.target == null` (= not loading)
|
|
||||||
* because during load Sonos reports Track=1 while we're still
|
|
||||||
* appending, and syncing would race the SetAV+Seek that lands
|
|
||||||
* after. Outside load, forward sync catches Sonos auto-advances
|
|
||||||
* (queue end-of-track), Sonos-app driven Next presses, and any
|
|
||||||
* drift after a brief poll-failure burst that didn't trip the
|
|
||||||
* drop threshold. Forward-only because a Next override we just
|
|
||||||
* issued can race with a poll still reporting the prior Track --
|
|
||||||
* the next poll catches up safely.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||||
val info = active.avTransport.getPositionInfo()
|
val info = active.avTransport.getPositionInfo()
|
||||||
@@ -553,7 +563,7 @@ class MinstrelForwardingPlayer(
|
|||||||
trackUri = info.trackUri,
|
trackUri = info.trackUri,
|
||||||
trackNumber = info.track,
|
trackNumber = info.track,
|
||||||
)
|
)
|
||||||
maybeSyncLocalCursor(info.track)
|
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||||
val transport = active.avTransport.getTransportInfo()
|
val transport = active.avTransport.getTransportInfo()
|
||||||
when (transport.state) {
|
when (transport.state) {
|
||||||
TransportState.PLAYING -> {
|
TransportState.PLAYING -> {
|
||||||
@@ -577,23 +587,95 @@ class MinstrelForwardingPlayer(
|
|||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
/**
|
||||||
if (holder.target.value != null) return
|
* Align the paused local delegate cursor to the track the renderer is
|
||||||
if (sonosTrack <= 0) return
|
* actually playing, so the un-overridden current-item getters
|
||||||
val sonosIdx = sonosTrack - 1
|
* (getCurrentMediaItem/Index -- what both the in-app player UI via
|
||||||
|
* PlayerController.onEvents AND the MediaSession notification read) always
|
||||||
|
* resolve to that track. This is THE single source of truth for "what's
|
||||||
|
* playing" during a cast.
|
||||||
|
*
|
||||||
|
* Identity-first: match the track-id embedded in the renderer's current URI
|
||||||
|
* against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
|
||||||
|
* TrackRef.id), so the index-offset wobble across queue reloads / re-casts
|
||||||
|
* can never park the cursor on a stale (pre-cast) track. Falls back to the
|
||||||
|
* 1-based Sonos Track index only when the URI can't be resolved to a queue
|
||||||
|
* item. Unlike the old forward-only sync this moves the cursor in BOTH
|
||||||
|
* directions -- a renderer Previous / re-cast to a lower track pulls it back
|
||||||
|
* too. Runs on the application looper (delegate access contract).
|
||||||
|
*
|
||||||
|
* Suppressed while:
|
||||||
|
* - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
|
||||||
|
* (re)uploaded, so its Track index is meaningless; and
|
||||||
|
* - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
|
||||||
|
* the override already advanced the delegate optimistically, and Sonos
|
||||||
|
* still reports the OLD track for a beat, so syncing now would undo the
|
||||||
|
* user's press. The pending deadline is honoured directly so a missed
|
||||||
|
* clear can't wedge the sync.
|
||||||
|
*/
|
||||||
|
private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
|
||||||
|
if (isLoadingUpnp()) return
|
||||||
|
val pendingDeadline = remoteState.pendingTransportDeadlineMs
|
||||||
|
if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
|
||||||
handler.post {
|
handler.post {
|
||||||
val localIdx = delegate.currentMediaItemIndex
|
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
|
||||||
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
if (target != delegate.currentMediaItemIndex) {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
"UPnP cursor sync: local=%d -> %d",
|
||||||
localIdx, sonosIdx,
|
delegate.currentMediaItemIndex, target,
|
||||||
)
|
)
|
||||||
delegate.seekTo(sonosIdx, 0L)
|
delegate.seekTo(target, 0L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most-authoritative -> most-degraded (project rule #48): the track-id
|
||||||
|
* parsed from the renderer's current URI wins; else the 1-based Sonos Track
|
||||||
|
* index; else null (leave the cursor put rather than surface a wrong track).
|
||||||
|
* Must run on the application looper -- reads the delegate timeline.
|
||||||
|
*/
|
||||||
|
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
|
||||||
|
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
|
||||||
|
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
|
||||||
|
}
|
||||||
|
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
|
||||||
|
* occurrence nearest [preferNear] so a track that appears twice in the queue
|
||||||
|
* doesn't snap the cursor across the queue. null if the id isn't present.
|
||||||
|
*/
|
||||||
|
private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
|
||||||
|
var best: Int? = null
|
||||||
|
var bestDist = Int.MAX_VALUE
|
||||||
|
for (i in 0 until delegate.mediaItemCount) {
|
||||||
|
if (delegate.getMediaItemAt(i).mediaId == mediaId) {
|
||||||
|
val dist = abs(i - preferNear)
|
||||||
|
if (dist < bestDist) {
|
||||||
|
best = i
|
||||||
|
bestDist = dist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
|
||||||
|
private fun trackIdFromStreamUri(uri: String): String? {
|
||||||
|
val start = uri.indexOf(TRACKS_PATH_MARKER)
|
||||||
|
if (start < 0) return null
|
||||||
|
val idStart = start + TRACKS_PATH_MARKER.length
|
||||||
|
val idEnd = uri.indexOf('/', idStart)
|
||||||
|
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
// Path segment that precedes the track-id in a stream URI
|
||||||
|
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
|
||||||
|
// back to a delegate MediaItem by identity.
|
||||||
|
const val TRACKS_PATH_MARKER = "/api/tracks/"
|
||||||
const val POLL_INTERVAL_MS = 1_000L
|
const val POLL_INTERVAL_MS = 1_000L
|
||||||
const val NON_PLAYING_CONFIRM = 2
|
const val NON_PLAYING_CONFIRM = 2
|
||||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
clientId = cid,
|
clientId = cid,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
|
deviceClass = DEVICE_CLASS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (curTrackId == trackId) {
|
if (curTrackId == trackId) {
|
||||||
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
atIso = startedAt.toString(),
|
atIso = startedAt.toString(),
|
||||||
durationPlayedMs = durationPlayedMs,
|
durationPlayedMs = durationPlayedMs,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
|
deviceClass = DEVICE_CLASS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
|
|
||||||
// ── Lifecycle: durable-close on app background / detach ────────
|
// ── Lifecycle: durable-close on app background / detach ────────
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// #1551: Android is a mobile client. Refine to tablet/tv via device
|
||||||
|
// configuration later if the metrics trend view shows it matters.
|
||||||
|
private const val DEVICE_CLASS = "mobile"
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStop(owner: LifecycleOwner) {
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||||
// alone: the foreground media service keeps the process — and this
|
// alone: the foreground media service keeps the process — and this
|
||||||
|
|||||||
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
|
|||||||
controller.addMediaItem(track.toMediaItem(source = null))
|
controller.addMediaItem(track.toMediaItem(source = null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder the queue: move the item at [from] to [to], keeping the domain
|
||||||
|
* snapshot in lock-step with the player's MediaItem timeline. Media3 emits
|
||||||
|
* onEvents → uiState reflects the new order (and the still-playing item's
|
||||||
|
* index). No-op on bad indices or a no-move.
|
||||||
|
*/
|
||||||
|
fun moveInQueue(from: Int, to: Int) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
|
||||||
|
queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
|
||||||
|
controller.moveMediaItem(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the queue item at [index]. When it's the currently-playing item
|
||||||
|
* Media3 advances to the next automatically. No-op on a bad index.
|
||||||
|
*/
|
||||||
|
fun removeFromQueue(index: Int) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (index !in queueRefs.indices) return
|
||||||
|
queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
|
||||||
|
controller.removeMediaItem(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty the queue and stop playback. */
|
||||||
|
fun clearQueue() {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
queueRefs = emptyList()
|
||||||
|
controller.clearMediaItems()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
||||||
* "radio:<id>" so the server-side rotation reporter can
|
* "radio:<id>" so the server-side rotation reporter can
|
||||||
@@ -598,18 +629,19 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One position-polling tick. Owns track-change detection too: when UPnP
|
* One position-polling tick. Patches ONLY the smoothly-changing transport
|
||||||
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
* fields (position / duration / play-pause / buffer) via `.copy()`. The
|
||||||
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
* queue index + current track are owned solely by [onEvents], which reads
|
||||||
* uiState.queueIndex stuck on the old track even after Sonos has
|
* the authoritative delegate cursor: during a cast,
|
||||||
* advanced. So the tick reads Sonos's reported Track as the source of
|
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
|
||||||
* truth, rebuilds the index/title fields itself, and force-syncs the
|
* the track the renderer is actually playing (matched by identity), so there
|
||||||
* wrapped player as defense in depth.
|
* is exactly one writer of "what's playing" and a stale tick can never
|
||||||
|
* revert it to the old track. (Previously the tick was a second index writer
|
||||||
|
* that fought onEvents — the flicker-to-stale-track bug.)
|
||||||
*/
|
*/
|
||||||
private fun tickPositionPoll(controller: MediaController) {
|
private fun tickPositionPoll(controller: MediaController) {
|
||||||
val upnpActive = activeUpnpHolder.active.value != null
|
val upnpActive = activeUpnpHolder.active.value != null
|
||||||
resolvePendingTransport(controller, upnpActive)
|
resolvePendingTransport(controller, upnpActive)
|
||||||
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
|
|
||||||
val effectiveIsPlaying =
|
val effectiveIsPlaying =
|
||||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||||
val effectivePosition = if (upnpActive) {
|
val effectivePosition = if (upnpActive) {
|
||||||
@@ -617,27 +649,29 @@ class PlayerController @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
controller.currentPosition
|
controller.currentPosition
|
||||||
}
|
}
|
||||||
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
val idx = controller.currentMediaItemIndex
|
||||||
val current = uiStateInternal.value
|
val current = uiStateInternal.value
|
||||||
val newPos = effectivePosition.coerceAtLeast(0)
|
val newPos = effectivePosition.coerceAtLeast(0)
|
||||||
val newDur = effectiveDuration(
|
val newDur = effectiveDuration(
|
||||||
upnpActive,
|
upnpActive,
|
||||||
remoteState.durationMs,
|
remoteState.durationMs,
|
||||||
controller.duration,
|
controller.duration,
|
||||||
desiredIdx = desiredIdx,
|
desiredIdx = idx,
|
||||||
controllerIdx = controller.currentMediaItemIndex,
|
controllerIdx = idx,
|
||||||
)
|
)
|
||||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||||
// Track adjustments are forward-only AND suppressed while a user
|
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
|
||||||
// transport press is pending Sonos confirmation. Together those keep
|
current.positionMs != newPos ||
|
||||||
// either direction of user input from being undone by a stale poll.
|
current.durationMs != newDur ||
|
||||||
val trackChanged = !pendingTransport &&
|
current.bufferedPositionMs != newBuf
|
||||||
desiredIdx > current.queueIndex &&
|
if (somethingChanged) {
|
||||||
desiredIdx in queueRefs.indices
|
uiStateInternal.value = current.copy(
|
||||||
publishTickIfChanged(
|
isPlaying = effectiveIsPlaying,
|
||||||
current, trackChanged, desiredIdx,
|
positionMs = newPos,
|
||||||
effectiveIsPlaying, newPos, newDur, newBuf,
|
durationMs = newDur,
|
||||||
)
|
bufferedPositionMs = newBuf,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -654,48 +688,6 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
|
|
||||||
private fun publishTickIfChanged(
|
|
||||||
current: PlayerUiState,
|
|
||||||
trackChanged: Boolean,
|
|
||||||
desiredIdx: Int,
|
|
||||||
effectiveIsPlaying: Boolean,
|
|
||||||
newPos: Long,
|
|
||||||
newDur: Long,
|
|
||||||
newBuf: Long,
|
|
||||||
) {
|
|
||||||
val somethingChanged = trackChanged ||
|
|
||||||
current.isPlaying != effectiveIsPlaying ||
|
|
||||||
current.positionMs != newPos ||
|
|
||||||
current.durationMs != newDur
|
|
||||||
if (!somethingChanged) return
|
|
||||||
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
|
|
||||||
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
|
|
||||||
uiStateInternal.value = current.copy(
|
|
||||||
currentTrack = newTrack,
|
|
||||||
queueIndex = newIdx,
|
|
||||||
isPlaying = effectiveIsPlaying,
|
|
||||||
positionMs = newPos,
|
|
||||||
durationMs = newDur,
|
|
||||||
bufferedPositionMs = newBuf,
|
|
||||||
)
|
|
||||||
// Intentionally do NOT call controller.seekTo here. That would route
|
|
||||||
// through MinstrelForwardingPlayer's seekTo override and re-issue
|
|
||||||
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
|
|
||||||
// start of the same track it's already playing, restarting the song.
|
|
||||||
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
|
|
||||||
// delegate.seekTo (which bypasses the override). If it lags briefly,
|
|
||||||
// the next pollOnce catches up; the uiState above already reflects
|
|
||||||
// Sonos's truth for the user.
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
|
|
||||||
if (upnpActive) {
|
|
||||||
(remoteState.trackNumber - 1).coerceAtLeast(0)
|
|
||||||
} else {
|
|
||||||
controller.currentMediaItemIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Remote position interpolation state ──────────────────────────────
|
// ── Remote position interpolation state ──────────────────────────────
|
||||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package com.fabledsword.minstrel.player.ui
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
import com.fabledsword.minstrel.player.PlayerUiState
|
import com.fabledsword.minstrel.player.PlayerUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,13 +25,29 @@ import javax.inject.Inject
|
|||||||
* stub-test for ViewModel-level logic when it grows).
|
* stub-test for ViewModel-level logic when it grows).
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
|
||||||
class PlayerViewModel @Inject constructor(
|
class PlayerViewModel @Inject constructor(
|
||||||
private val controller: PlayerController,
|
private val controller: PlayerController,
|
||||||
|
private val likes: LikesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||||
val dropEvents: SharedFlow<String> = controller.dropEvents
|
val dropEvents: SharedFlow<String> = controller.dropEvents
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive set of liked track ids — the set-based pattern the queue
|
||||||
|
* (and playlist/album/artist detail) uses to color each row's
|
||||||
|
* [com.fabledsword.minstrel.shared.widgets.LikeButton] without a
|
||||||
|
* per-row Flow subscription.
|
||||||
|
*/
|
||||||
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
|
likes.observeLikedTrackIds()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
|
initialValue = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
fun play() = controller.play()
|
fun play() = controller.play()
|
||||||
fun pause() = controller.pause()
|
fun pause() = controller.pause()
|
||||||
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
||||||
@@ -35,4 +56,16 @@ class PlayerViewModel @Inject constructor(
|
|||||||
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
||||||
fun toggleShuffle() = controller.toggleShuffle()
|
fun toggleShuffle() = controller.toggleShuffle()
|
||||||
fun cycleRepeat() = controller.cycleRepeat()
|
fun cycleRepeat() = controller.cycleRepeat()
|
||||||
|
fun moveInQueue(from: Int, to: Int) = controller.moveInQueue(from, to)
|
||||||
|
fun removeFromQueue(index: Int) = controller.removeFromQueue(index)
|
||||||
|
fun clearQueue() = controller.clearQueue()
|
||||||
|
|
||||||
|
fun toggleLikeTrack(trackId: String) {
|
||||||
|
val desired = trackId !in likedTrackIds.value
|
||||||
|
viewModelScope.launch {
|
||||||
|
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
package com.fabledsword.minstrel.player.ui
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -20,22 +28,43 @@ 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.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
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.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
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 androidx.compose.ui.zIndex
|
||||||
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.GripVertical
|
||||||
import com.composables.icons.lucide.Lucide
|
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.composables.icons.lucide.Volume2
|
||||||
|
import com.composables.icons.lucide.X
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
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 com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -44,16 +73,35 @@ fun QueueScreen(
|
|||||||
viewModel: PlayerViewModel = hiltViewModel(),
|
viewModel: PlayerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Queue") },
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("Queue")
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = queueSummary(state.queue),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navController.popBackStack() }) {
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
IconButton(onClick = viewModel::clearQueue) {
|
||||||
|
Icon(Lucide.Trash2, contentDescription = "Clear queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
@@ -67,33 +115,109 @@ fun QueueScreen(
|
|||||||
QueueList(
|
QueueList(
|
||||||
tracks = state.queue,
|
tracks = state.queue,
|
||||||
currentIndex = state.queueIndex,
|
currentIndex = state.queueIndex,
|
||||||
|
likedTrackIds = likedTrackIds,
|
||||||
onJumpTo = viewModel::seekToIndex,
|
onJumpTo = viewModel::seekToIndex,
|
||||||
|
onToggleLike = viewModel::toggleLikeTrack,
|
||||||
|
onMove = viewModel::moveInQueue,
|
||||||
|
onRemove = viewModel::removeFromQueue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueList(
|
private fun QueueList(
|
||||||
tracks: List<TrackRef>,
|
tracks: List<TrackRef>,
|
||||||
currentIndex: Int,
|
currentIndex: Int,
|
||||||
|
likedTrackIds: Set<String>,
|
||||||
onJumpTo: (Int) -> Unit,
|
onJumpTo: (Int) -> Unit,
|
||||||
|
onToggleLike: (String) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
onRemove: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
val listState = rememberLazyListState(
|
||||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||||
QueueRow(
|
)
|
||||||
track = track,
|
val scope = rememberCoroutineScope()
|
||||||
isCurrent = index == currentIndex,
|
|
||||||
onClick = { onJumpTo(index) },
|
// Follow the now-playing row as the track auto-advances, but only while it's
|
||||||
)
|
// near the visible window — if the user has scrolled away to browse, leave
|
||||||
HorizontalDivider()
|
// them there (the pill offers the way back). Parity with the web queue.
|
||||||
|
LaunchedEffect(currentIndex) {
|
||||||
|
if (currentIndex < 0) return@LaunchedEffect
|
||||||
|
val visible = listState.layoutInfo.visibleItemsInfo
|
||||||
|
val first = visible.firstOrNull()?.index ?: 0
|
||||||
|
val last = visible.lastOrNull()?.index ?: 0
|
||||||
|
if (currentIndex in (first - 1)..(last + 1)) {
|
||||||
|
listState.animateScrollToItem(currentIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val currentVisible by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.layoutInfo.visibleItemsInfo.any { it.index == currentIndex }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||||
|
QueueRow(
|
||||||
|
track = track,
|
||||||
|
index = index,
|
||||||
|
queueSize = tracks.size,
|
||||||
|
isCurrent = index == currentIndex,
|
||||||
|
liked = track.id in likedTrackIds,
|
||||||
|
onClick = { onJumpTo(index) },
|
||||||
|
onToggleLike = { onToggleLike(track.id) },
|
||||||
|
onRemove = { onRemove(index) },
|
||||||
|
onMove = onMove,
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JumpToCurrentPill(
|
||||||
|
visible = currentIndex >= 0 && !currentVisible,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
private fun JumpToCurrentPill(
|
||||||
|
visible: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(visible = visible, modifier = modifier) {
|
||||||
|
FilledTonalButton(onClick = onClick) {
|
||||||
|
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("Jump to current")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||||
|
@Composable
|
||||||
|
private 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) {
|
val highlight = if (isCurrent) {
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||||
} else {
|
} else {
|
||||||
@@ -102,12 +226,23 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.onSizeChanged { rowHeightPx = it.height }
|
||||||
|
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||||
|
.graphicsLayer { translationY = dragOffsetY }
|
||||||
.background(highlight)
|
.background(highlight)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
DragHandle(
|
||||||
|
index = index,
|
||||||
|
queueSize = queueSize,
|
||||||
|
rowHeightPx = rowHeightPx,
|
||||||
|
onOffsetChange = { dragOffsetY = it },
|
||||||
|
onMove = onMove,
|
||||||
|
)
|
||||||
|
QueueRowThumbnail(track = track)
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
Icon(
|
Icon(
|
||||||
Lucide.Volume2,
|
Lucide.Volume2,
|
||||||
@@ -115,26 +250,7 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
|||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
QueueRowText(track = track, isCurrent = isCurrent, 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) {
|
if (track.durationSec > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(track.durationSec),
|
text = formatDuration(track.durationSec),
|
||||||
@@ -142,6 +258,95 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(Lucide.X, contentDescription = "Remove from queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DragHandle(
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
|
rowHeightPx: Int,
|
||||||
|
onOffsetChange: (Float) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
// 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) }
|
||||||
|
Icon(
|
||||||
|
Lucide.GripVertical,
|
||||||
|
contentDescription = "Reorder track",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.pointerInput(index, queueSize, rowHeightPx) {
|
||||||
|
detectDragGestures(
|
||||||
|
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) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,4 +355,18 @@ private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, tr
|
|||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
.joinToString(" · ")
|
.joinToString(" · ")
|
||||||
|
|
||||||
|
/** "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 HIGHLIGHT_ALPHA = 0.12f
|
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||||
|
private const val SECONDS_PER_MINUTE = 60
|
||||||
|
private const val MINUTES_PER_HOUR = 60
|
||||||
|
|||||||
+3
-3
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+132
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+314
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-11
@@ -9,11 +9,17 @@ Minstrel's four workflows consume two CI images:
|
|||||||
|
|
||||||
```
|
```
|
||||||
git.fabledsword.com/bvandeusen/ci-go:1.26
|
git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
git.fabledsword.com/bvandeusen/ci-android:36
|
||||||
```
|
```
|
||||||
|
|
||||||
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`.gitea/workflows/release.yml`).
|
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job).
|
||||||
- `ci-flutter:3.44` — Flutter client tests + debug/release APK builds (`.gitea/workflows/flutter.yml`).
|
- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job).
|
||||||
|
|
||||||
|
**`ci-flutter` is no longer consumed.** The M8 rewrite replaced the Flutter
|
||||||
|
client with the native Android app and `flutter.yml` was removed; `ci-android`
|
||||||
|
took its place. `flutter_client/` is still in the tree but nothing builds it.
|
||||||
|
CI-Runner still publishes `ci-flutter` and will retire it once that directory
|
||||||
|
goes — so if the Flutter client is ever revived, say so there first.
|
||||||
|
|
||||||
## Image deps used
|
## Image deps used
|
||||||
|
|
||||||
@@ -25,13 +31,20 @@ git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
|||||||
- **docker buildx** — release container build + push in `release.yml`.
|
- **docker buildx** — release container build + push in `release.yml`.
|
||||||
- **curl** — release-asset polling / upload in `release.yml`.
|
- **curl** — release-asset polling / upload in `release.yml`.
|
||||||
|
|
||||||
### From `ci-flutter:3.44`
|
### From `ci-android:36`
|
||||||
- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release).
|
- **JDK 25** — Gradle launcher + Android build. Requires Gradle 9.1.0+ in
|
||||||
- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen).
|
`android/gradle/wrapper`; older Gradle rejects JDK 25 with an opaque `"25.0.3"`
|
||||||
- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing.
|
error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED`
|
||||||
- **Java 25** — Gradle / Android build.
|
to silence Gradle's launcher-JVM restricted-method warning.
|
||||||
|
- **Android SDK + cmdline-tools + build-tools 36.0.0** — APK assembly + signing.
|
||||||
|
No NDK: the native client has no C/C++ sources (this is why it isn't on
|
||||||
|
`ci-flutter`).
|
||||||
|
- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in
|
||||||
|
`android.yml`. Image pins track `android/gradle/libs.versions.toml` so local
|
||||||
|
and CI checks agree.
|
||||||
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
||||||
- **base64 + curl** — keystore decode + release-asset upload in the tag-build path.
|
- **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s
|
||||||
|
`android-release` job.
|
||||||
|
|
||||||
## Per-job tool installs
|
## Per-job tool installs
|
||||||
|
|
||||||
@@ -39,9 +52,69 @@ None.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions.
|
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way.
|
||||||
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
||||||
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
||||||
- **In-app update channel polling.** `release.yml` polls Gitea's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure).
|
- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
|
||||||
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
||||||
|
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
|
||||||
|
```yaml
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||||
|
```
|
||||||
|
Upstream's `@v4+` cannot work against this instance and no server-side change
|
||||||
|
will help: `isGhes()` rejects any hostname that isn't `github.com` /
|
||||||
|
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
|
||||||
|
server is never asked what it supports. `@v3` is worse — it reports success,
|
||||||
|
and Gitea then serves artifacts back only through the v4 API
|
||||||
|
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
|
||||||
|
to every retrieval path. A green job producing nothing retrievable; that is how
|
||||||
|
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
|
||||||
|
|
||||||
|
Both are pull mirrors of the Forgejo project's forks
|
||||||
|
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
|
||||||
|
disabling that check), mirrored so CI depends on commits we hold and pinned by
|
||||||
|
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
|
||||||
|
otherwise silently change what runs.
|
||||||
|
|
||||||
|
**Match the pins on `@actions/artifact`, not on the actions' own version
|
||||||
|
numbers.** The two actions release on unrelated cadences, so equal version
|
||||||
|
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
|
||||||
|
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
|
||||||
|
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
|
||||||
|
matters because `release.yml` is a producer/consumer pair — `android-release`
|
||||||
|
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
|
||||||
|
across it yields an empty listing rather than an error, exactly the silent
|
||||||
|
failure this entry exists to prevent.
|
||||||
|
|
||||||
|
| tag | `@actions/artifact` | runtime |
|
||||||
|
|---|---|---|
|
||||||
|
| upload v4 | ^2.1.1 | node20 |
|
||||||
|
| **upload v5** ← pinned | **^4.0.0** | node20 |
|
||||||
|
| download v4 | ^2.1.1 | node20 |
|
||||||
|
| download v5 | ^2.3.2 | node20 |
|
||||||
|
| **download v6** ← pinned | **^4.0.0** | node20 |
|
||||||
|
| download v7 | ^5.0.0 | **node24** |
|
||||||
|
|
||||||
|
The only true protocol break in this history was **v3 → v4** (upstream:
|
||||||
|
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
|
||||||
|
below are not supported"); v4-and-up are one family. Later majors are mostly
|
||||||
|
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
|
||||||
|
at 500 artifacts; download v5 made by-ID extraction match by-name.
|
||||||
|
|
||||||
|
**Do not jump the download pin to v7.** That major is a runner requirement, not
|
||||||
|
a feature change: it moves to `runs.using: node24` and upstream states it
|
||||||
|
"requires a minimum Actions Runner version of 2.327.1 … if you are using
|
||||||
|
self-hosted runners, ensure they are updated before upgrading." act_runner is
|
||||||
|
not GitHub's runner and makes no such version claim, so node24 is unverified
|
||||||
|
here. Everything currently pinned is node20.
|
||||||
|
|
||||||
|
Upload steps set `if-no-files-found: error` rather than the default `warn`, so
|
||||||
|
an upload that matches nothing fails its own job instead of failing the
|
||||||
|
consumer later.
|
||||||
|
|
||||||
|
Retrieval: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
|
||||||
|
for the id (global run id, not the repo-scoped run number), then
|
||||||
|
`…/actions/artifacts/{id}/zip`. The workstation has no `unzip` — use
|
||||||
|
`python3 -m zipfile -e`.
|
||||||
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
|
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
@@ -206,6 +207,12 @@ func run() error {
|
|||||||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||||||
go similarityWorker.Run(ctx)
|
go similarityWorker.Run(ctx)
|
||||||
|
|
||||||
|
// Start the household co-play worker (#1533). Recomputes artist–artist
|
||||||
|
// co-occurrence edges (source='user_cooccurrence') from play_events every
|
||||||
|
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
|
||||||
|
// SQL, no external calls; empty on single-user servers.
|
||||||
|
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
|
||||||
|
|
||||||
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
||||||
// tag providers with tag_provider_settings, bumps the sources version if
|
// tag providers with tag_provider_settings, bumps the sources version if
|
||||||
// the provider set changed (re-opening settled rows), then drains tracks
|
// the provider set changed (re-opening settled rows), then drains tracks
|
||||||
|
|||||||
@@ -20,26 +20,28 @@ import (
|
|||||||
// weightsResp is one weight profile on the wire, keyed by the same
|
// weightsResp is one weight profile on the wire, keyed by the same
|
||||||
// snake_case field names the PATCH body accepts.
|
// snake_case field names the PATCH body accepts.
|
||||||
type weightsResp struct {
|
type weightsResp struct {
|
||||||
BaseWeight float64 `json:"base_weight"`
|
BaseWeight float64 `json:"base_weight"`
|
||||||
LikeBoost float64 `json:"like_boost"`
|
LikeBoost float64 `json:"like_boost"`
|
||||||
RecencyWeight float64 `json:"recency_weight"`
|
RecencyWeight float64 `json:"recency_weight"`
|
||||||
SkipPenalty float64 `json:"skip_penalty"`
|
SkipPenalty float64 `json:"skip_penalty"`
|
||||||
JitterMagnitude float64 `json:"jitter_magnitude"`
|
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||||
ContextWeight float64 `json:"context_weight"`
|
ContextWeight float64 `json:"context_weight"`
|
||||||
SimilarityWeight float64 `json:"similarity_weight"`
|
SimilarityWeight float64 `json:"similarity_weight"`
|
||||||
TasteWeight float64 `json:"taste_weight"`
|
TasteWeight float64 `json:"taste_weight"`
|
||||||
|
ContextTimeWeight float64 `json:"context_time_weight"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
||||||
return weightsResp{
|
return weightsResp{
|
||||||
BaseWeight: w.BaseWeight,
|
BaseWeight: w.BaseWeight,
|
||||||
LikeBoost: w.LikeBoost,
|
LikeBoost: w.LikeBoost,
|
||||||
RecencyWeight: w.RecencyWeight,
|
RecencyWeight: w.RecencyWeight,
|
||||||
SkipPenalty: w.SkipPenalty,
|
SkipPenalty: w.SkipPenalty,
|
||||||
JitterMagnitude: w.JitterMagnitude,
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
ContextWeight: w.ContextWeight,
|
ContextWeight: w.ContextWeight,
|
||||||
SimilarityWeight: w.SimilarityWeight,
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
TasteWeight: w.TasteWeight,
|
TasteWeight: w.TasteWeight,
|
||||||
|
ContextTimeWeight: w.ContextTimeWeight,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,9 @@ type tasteTuningResp struct {
|
|||||||
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||||
EngagementNeutral float64 `json:"engagement_neutral"`
|
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||||
EngagementFull float64 `json:"engagement_full"`
|
EngagementFull float64 `json:"engagement_full"`
|
||||||
|
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
||||||
|
EraScale float64 `json:"era_scale"`
|
||||||
|
MoodScale float64 `json:"mood_scale"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||||
@@ -56,6 +61,22 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
|||||||
EngagementHardSkip: t.EngagementHardSkip,
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
EngagementNeutral: t.EngagementNeutral,
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
EngagementFull: t.EngagementFull,
|
EngagementFull: t.EngagementFull,
|
||||||
|
EnrichedTagScale: t.EnrichedTagScale,
|
||||||
|
EraScale: t.EraScale,
|
||||||
|
MoodScale: t.MoodScale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverTuningResp is the Discover scope on the wire (#2377).
|
||||||
|
type discoverTuningResp struct {
|
||||||
|
TagOverlapWeight float64 `json:"tag_overlap_weight"`
|
||||||
|
SnoozeDays float64 `json:"snooze_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverRespFrom(d recsettings.DiscoverTuning) discoverTuningResp {
|
||||||
|
return discoverTuningResp{
|
||||||
|
TagOverlapWeight: d.TagOverlapWeight,
|
||||||
|
SnoozeDays: d.SnoozeDays,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +86,11 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
|||||||
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,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 {
|
||||||
|
|||||||
@@ -104,6 +104,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)
|
||||||
|
|||||||
+33
-2
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -24,6 +25,33 @@ type eventRequest struct {
|
|||||||
// / "discover" feed the system-playlist rotation dedup (#415);
|
// / "discover" feed the system-playlist rotation dedup (#415);
|
||||||
// absent / "" for library, user-playlist, radio, Subsonic.
|
// absent / "" for library, user-playlist, radio, Subsonic.
|
||||||
Source *string `json:"source"`
|
Source *string `json:"source"`
|
||||||
|
// DeviceClass is the client's self-reported device class (#1551),
|
||||||
|
// normalized + stored per play to condition radio on the current device.
|
||||||
|
DeviceClass *string `json:"device_class"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownDeviceClasses whitelists the device classes the context-affinity facet
|
||||||
|
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
|
||||||
|
// "other", and empty/absent to "" (stored NULL → excluded from the device
|
||||||
|
// dimension). Kept permissive (no DB CHECK) so a new client class is one
|
||||||
|
// whitelist entry, not a migration.
|
||||||
|
var knownDeviceClasses = map[string]bool{
|
||||||
|
"mobile": true, "tablet": true, "desktop": true,
|
||||||
|
"web": true, "tv": true, "watch": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDeviceClass(dc *string) string {
|
||||||
|
if dc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := strings.ToLower(strings.TrimSpace(*dc))
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if knownDeviceClasses[s] {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "other"
|
||||||
}
|
}
|
||||||
|
|
||||||
type playStartedResponse struct {
|
type playStartedResponse struct {
|
||||||
@@ -114,7 +142,9 @@ func (h *handlers) handleEventPlayStarted(
|
|||||||
if req.Source != nil {
|
if req.Source != nil {
|
||||||
source = *req.Source
|
source = *req.Source
|
||||||
}
|
}
|
||||||
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
|
deviceClass := normalizeDeviceClass(req.DeviceClass)
|
||||||
|
res, err := h.events.RecordPlayStartedWithSource(
|
||||||
|
r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: events: play_started", "err", err)
|
h.logger.Error("api: events: play_started", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||||
@@ -159,7 +189,8 @@ func (h *handlers) handleEventPlayOffline(
|
|||||||
source = *req.Source
|
source = *req.Source
|
||||||
}
|
}
|
||||||
if err := h.events.RecordOfflinePlay(
|
if err := h.events.RecordOfflinePlay(
|
||||||
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
|
r.Context(), user.ID, trackID, clientID, source,
|
||||||
|
normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
h.logger.Error("api: events: play_offline", "err", err)
|
h.logger.Error("api: events: play_offline", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -81,6 +82,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
||||||
|
// Condition on the current device (#1551): the latest play's device is a
|
||||||
|
// cheap, request-free proxy for what the user is on right now.
|
||||||
|
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
|
||||||
|
|
||||||
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
||||||
limits := recommendation.DefaultCandidateSourceLimits()
|
limits := recommendation.DefaultCandidateSourceLimits()
|
||||||
@@ -150,6 +154,24 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// latestDeviceClass returns the device_class of the user's most recent play as
|
||||||
|
// the "current device" for context conditioning (#1551), or "" when unknown
|
||||||
|
// (no plays yet, or the latest play predates device capture). Best-effort: a
|
||||||
|
// lookup failure yields a device-agnostic ("") affinity cell.
|
||||||
|
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
|
||||||
|
dc, err := q.GetLatestPlayDeviceClassForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
logger.Warn("api: radio: latest device class", "err", err)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if dc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *dc
|
||||||
|
}
|
||||||
|
|
||||||
// parseExcludeParam parses a comma-separated list of UUIDs from the
|
// parseExcludeParam parses a comma-separated list of UUIDs from the
|
||||||
// `exclude` query string, silently dropping malformed entries. Returns
|
// `exclude` query string, silently dropping malformed entries. Returns
|
||||||
// nil for empty or all-malformed input.
|
// nil for empty or all-malformed input.
|
||||||
|
|||||||
+159
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Package coplay builds household co-play similarity edges (#1533, milestone
|
||||||
|
// #160 Opt 5). Minstrel is a single shared-library, multi-user server (no
|
||||||
|
// per-user library ACL), so the "household" is the whole instance's user set.
|
||||||
|
// A periodic worker recomputes artist–artist co-occurrence from play_events
|
||||||
|
// entirely in SQL (no external API) and stores the edges in artist_similarity
|
||||||
|
// under source='user_cooccurrence' — the pre-provisioned co-occurrence source
|
||||||
|
// from the 0009 schema — which the radio/mix candidate pool consumes as a
|
||||||
|
// collaborative arm. Single-user servers produce no edges (the >= 2 co-player
|
||||||
|
// gate), so the feature is a graceful no-op there.
|
||||||
|
package coplay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// minJaccard is the co-play edge floor: prune weak pairs and self-limit hub
|
||||||
|
// artists, whose large combined-audience denominators pull their Jaccard down.
|
||||||
|
// 0.1 keeps only pairs whose co-player overlap is a real fraction of their
|
||||||
|
// combined audience.
|
||||||
|
const minJaccard = 0.1
|
||||||
|
|
||||||
|
// Worker periodically rebuilds the household co-play edge set. Pure local SQL,
|
||||||
|
// so there are no rate limits — but the pair self-join is roughly
|
||||||
|
// O(users × artists_per_user²), so it ticks slowly: co-listening shifts over
|
||||||
|
// days, not minutes, and a frequent rebuild would burn CPU for no fresher
|
||||||
|
// signal.
|
||||||
|
type Worker struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
logger *slog.Logger
|
||||||
|
tick time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorker constructs a worker with the production default 6h tick.
|
||||||
|
func NewWorker(pool *pgxpool.Pool, logger *slog.Logger) *Worker {
|
||||||
|
return &Worker{pool: pool, logger: logger, tick: 6 * time.Hour}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run rebuilds once at startup (so edges exist without waiting a full tick),
|
||||||
|
// then every w.tick until ctx is cancelled. Runs in its own goroutine, so a
|
||||||
|
// slow initial rebuild never blocks boot.
|
||||||
|
func (w *Worker) Run(ctx context.Context) {
|
||||||
|
if err := w.rebuild(ctx); err != nil {
|
||||||
|
w.logger.Error("coplay: initial rebuild failed", "err", err)
|
||||||
|
}
|
||||||
|
t := time.NewTicker(w.tick)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := w.rebuild(ctx); err != nil {
|
||||||
|
w.logger.Error("coplay: rebuild failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rebuild atomic-replaces the user_cooccurrence edge set inside one
|
||||||
|
// transaction — delete the old edges, recompute from current play history — so
|
||||||
|
// the candidate pool never observes a half-built set.
|
||||||
|
func (w *Worker) rebuild(ctx context.Context) error {
|
||||||
|
tx, err := w.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
q := dbq.New(tx)
|
||||||
|
if err := q.DeleteArtistCoplayEdges(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := q.InsertArtistCoplayEdges(ctx, minJaccard); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.logger.Debug("coplay: edges rebuilt")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package coplay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testPool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping in -short mode")
|
||||||
|
}
|
||||||
|
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
dbtest.ResetDB(t, pool)
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||||
|
|
||||||
|
func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID {
|
||||||
|
t.Helper()
|
||||||
|
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
|
||||||
|
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed user %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return u.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedArtistTrack creates an artist + album + one track, returning both ids.
|
||||||
|
func seedArtistTrack(t *testing.T, q *dbq.Queries, name string) (pgtype.UUID, pgtype.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed artist: %v", err)
|
||||||
|
}
|
||||||
|
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: a.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed album: %v", err)
|
||||||
|
}
|
||||||
|
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: name, AlbumID: al.ID, ArtistID: a.ID,
|
||||||
|
DurationMs: 1000, FilePath: "/tmp/coplay-" + name + ".mp3",
|
||||||
|
FileSize: 1, FileFormat: "mp3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed track: %v", err)
|
||||||
|
}
|
||||||
|
return a.ID, tr.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func play(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
at := time.Now()
|
||||||
|
var sessionID pgtype.UUID
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||||
|
VALUES ($1, $2, $2, 'coplay-test') RETURNING id`,
|
||||||
|
userID, at).Scan(&sessionID); err != nil {
|
||||||
|
t.Fatalf("insert play_session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
|
||||||
|
VALUES ($1, $2, $3, $4, false)`,
|
||||||
|
userID, trackID, sessionID, at); err != nil {
|
||||||
|
t.Fatalf("insert play_event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coplayEdgeCount(t *testing.T, pool *pgxpool.Pool) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
if err := pool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM artist_similarity WHERE source='user_cooccurrence'`).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count edges: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCoplayWorker_BuildsEdges: two users who both play two artists produce a
|
||||||
|
// perfect-overlap co-play edge (Jaccard 1.0) in each direction.
|
||||||
|
func TestCoplayWorker_BuildsEdges(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
u1 := seedUser(t, q, "cop1")
|
||||||
|
u2 := seedUser(t, q, "cop2")
|
||||||
|
aA, tA := seedArtistTrack(t, q, "ArtA")
|
||||||
|
aB, tB := seedArtistTrack(t, q, "ArtB")
|
||||||
|
|
||||||
|
for _, u := range []pgtype.UUID{u1, u2} {
|
||||||
|
play(t, pool, u, tA)
|
||||||
|
play(t, pool, u, tB)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil {
|
||||||
|
t.Fatalf("rebuild: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var score float64
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT score FROM artist_similarity
|
||||||
|
WHERE artist_a_id=$1 AND artist_b_id=$2 AND source='user_cooccurrence'`,
|
||||||
|
aA, aB).Scan(&score); err != nil {
|
||||||
|
t.Fatalf("expected co-play edge ArtA→ArtB: %v", err)
|
||||||
|
}
|
||||||
|
// Jaccard of perfectly-overlapping player sets (both users play both):
|
||||||
|
// coplayers=2, players each=2 → 2/(2+2-2) = 1.0.
|
||||||
|
if score != 1.0 {
|
||||||
|
t.Errorf("edge score = %v, want 1.0 (perfect overlap)", score)
|
||||||
|
}
|
||||||
|
if got := coplayEdgeCount(t, pool); got != 2 {
|
||||||
|
t.Errorf("edge count = %d, want 2 (A→B and B→A)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCoplayWorker_SingleUserNoEdges: one user's own co-listening yields no
|
||||||
|
// edges — the >= 2 distinct-co-player gate keeps single-user servers empty.
|
||||||
|
func TestCoplayWorker_SingleUserNoEdges(t *testing.T) {
|
||||||
|
pool := testPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
u := seedUser(t, q, "solo")
|
||||||
|
_, tA := seedArtistTrack(t, q, "SoloA")
|
||||||
|
_, tB := seedArtistTrack(t, q, "SoloB")
|
||||||
|
play(t, pool, u, tA)
|
||||||
|
play(t, pool, u, tB)
|
||||||
|
|
||||||
|
if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil {
|
||||||
|
t.Fatalf("rebuild: %v", err)
|
||||||
|
}
|
||||||
|
if got := coplayEdgeCount(t, pool); got != 0 {
|
||||||
|
t.Errorf("single-user edge count = %d, want 0 (>= 2 co-player gate)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: coplay.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteArtistCoplayEdges = `-- name: DeleteArtistCoplayEdges :exec
|
||||||
|
|
||||||
|
DELETE FROM artist_similarity WHERE source = 'user_cooccurrence'
|
||||||
|
`
|
||||||
|
|
||||||
|
// Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a
|
||||||
|
// single shared-library, multi-user server (no per-user library ACL), so the
|
||||||
|
// "household" is the whole instance's user set. These queries recompute
|
||||||
|
// artist–artist co-occurrence edges from play_events and store them in
|
||||||
|
// artist_similarity under the pre-provisioned source = 'user_cooccurrence'
|
||||||
|
// (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then
|
||||||
|
// re-INSERTs so edges that fall below threshold disappear. Single-user servers
|
||||||
|
// produce no edges (the >= 2 co-player gate), so the arm is empty there.
|
||||||
|
func (q *Queries) DeleteArtistCoplayEdges(ctx context.Context) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteArtistCoplayEdges)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertArtistCoplayEdges = `-- name: InsertArtistCoplayEdges :exec
|
||||||
|
WITH user_artists AS (
|
||||||
|
SELECT DISTINCT pe.user_id, t.artist_id
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.was_skipped = false
|
||||||
|
AND pe.started_at > now() - interval '365 days'
|
||||||
|
),
|
||||||
|
artist_players AS (
|
||||||
|
SELECT artist_id, count(*)::float8 AS players
|
||||||
|
FROM user_artists
|
||||||
|
GROUP BY artist_id
|
||||||
|
),
|
||||||
|
pairs AS (
|
||||||
|
SELECT ua.artist_id AS a_id,
|
||||||
|
ub.artist_id AS b_id,
|
||||||
|
count(*)::float8 AS coplayers
|
||||||
|
FROM user_artists ua
|
||||||
|
JOIN user_artists ub
|
||||||
|
ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id
|
||||||
|
GROUP BY ua.artist_id, ub.artist_id
|
||||||
|
HAVING count(*) >= 2
|
||||||
|
),
|
||||||
|
scored AS (
|
||||||
|
SELECT p.a_id, p.b_id,
|
||||||
|
p.coplayers / (pa.players + pb.players - p.coplayers) AS score
|
||||||
|
FROM pairs p
|
||||||
|
JOIN artist_players pa ON pa.artist_id = p.a_id
|
||||||
|
JOIN artist_players pb ON pb.artist_id = p.b_id
|
||||||
|
)
|
||||||
|
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
|
||||||
|
SELECT a_id, b_id, score, 'user_cooccurrence', now()
|
||||||
|
FROM scored
|
||||||
|
WHERE score >= $1
|
||||||
|
ON CONFLICT (artist_a_id, artist_b_id, source)
|
||||||
|
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at
|
||||||
|
`
|
||||||
|
|
||||||
|
// Two artists are "co-played" when the same users play both. score is the
|
||||||
|
// Jaccard of their distinct-player sets — coplayers / (playersA + playersB −
|
||||||
|
// coplayers) in (0,1] — which controls for globally-popular artists (an artist
|
||||||
|
// everyone plays would otherwise co-occur with everything). Only pairs with
|
||||||
|
// >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak
|
||||||
|
// edges and self-limits hub artists, whose large denominators drag their
|
||||||
|
// Jaccard down) are kept. Completed plays only, 365-day window.
|
||||||
|
func (q *Queries) InsertArtistCoplayEdges(ctx context.Context, score float64) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertArtistCoplayEdges, score)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
||||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events
|
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events
|
||||||
WHERE user_id = $1 AND ended_at IS NULL
|
WHERE user_id = $1 AND ended_at IS NULL
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -80,12 +80,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
|
|||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
&i.PickKind,
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
||||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events WHERE id = $1
|
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
||||||
@@ -106,6 +107,7 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
|
|||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
&i.PickKind,
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -144,19 +146,22 @@ func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPi
|
|||||||
|
|
||||||
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
||||||
INSERT INTO play_events (
|
INSERT INTO play_events (
|
||||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
|
device_class
|
||||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
|
) VALUES ($1, $2, $3, $4, $5, $6, $7::text,
|
||||||
|
$8::text)
|
||||||
|
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertPlayEventParams struct {
|
type InsertPlayEventParams struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
SessionID pgtype.UUID
|
SessionID pgtype.UUID
|
||||||
StartedAt pgtype.Timestamptz
|
StartedAt pgtype.Timestamptz
|
||||||
ClientID *string
|
ClientID *string
|
||||||
Source *string
|
Source *string
|
||||||
PickKind *string
|
PickKind *string
|
||||||
|
DeviceClass *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// pick_kind is non-NULL only for system-playlist plays whose track was
|
// pick_kind is non-NULL only for system-playlist plays whose track was
|
||||||
@@ -171,6 +176,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
|||||||
arg.ClientID,
|
arg.ClientID,
|
||||||
arg.Source,
|
arg.Source,
|
||||||
arg.PickKind,
|
arg.PickKind,
|
||||||
|
arg.DeviceClass,
|
||||||
)
|
)
|
||||||
var i PlayEvent
|
var i PlayEvent
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -188,6 +194,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
|||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
&i.PickKind,
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -333,7 +340,7 @@ SET ended_at = $2,
|
|||||||
completion_ratio = $4,
|
completion_ratio = $4,
|
||||||
was_skipped = $5
|
was_skipped = $5
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
|
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdatePlayEventEndedParams struct {
|
type UpdatePlayEventEndedParams struct {
|
||||||
@@ -371,6 +378,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
|
|||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
&i.PickKind,
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
+57
-10
@@ -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
|
||||||
@@ -384,6 +404,7 @@ type PlayEvent struct {
|
|||||||
ScrobbledAt pgtype.Timestamptz
|
ScrobbledAt pgtype.Timestamptz
|
||||||
Source *string
|
Source *string
|
||||||
PickKind *string
|
PickKind *string
|
||||||
|
DeviceClass *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaySession struct {
|
type PlaySession struct {
|
||||||
@@ -446,16 +467,17 @@ type RecommendationTuningAudit struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RecommendationWeightProfile struct {
|
type RecommendationWeightProfile struct {
|
||||||
Profile string
|
Profile string
|
||||||
BaseWeight float64
|
BaseWeight float64
|
||||||
LikeBoost float64
|
LikeBoost float64
|
||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
SimilarityWeight float64
|
SimilarityWeight float64
|
||||||
TasteWeight float64
|
TasteWeight float64
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
ContextTimeWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegistrationSetting struct {
|
type RegistrationSetting struct {
|
||||||
@@ -516,6 +538,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
|
||||||
@@ -554,6 +584,20 @@ type TasteProfileArtist struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TasteProfileEra struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Era string
|
||||||
|
Weight float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type TasteProfileMood struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Mood string
|
||||||
|
Weight float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type TasteProfileTag struct {
|
type TasteProfileTag struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
Tag string
|
Tag string
|
||||||
@@ -568,6 +612,9 @@ type TasteTuning struct {
|
|||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
EnrichedTagScale float64
|
||||||
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
|
|||||||
@@ -11,6 +11,110 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getLatestPlayDeviceClassForUser = `-- name: GetLatestPlayDeviceClassForUser :one
|
||||||
|
SELECT device_class
|
||||||
|
FROM play_events
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// The device_class of the user's most recent play (#1551), used as the
|
||||||
|
// "current device" for radio context conditioning. NULL when the latest play
|
||||||
|
// predates device capture or came from a client that didn't send one.
|
||||||
|
func (q *Queries) GetLatestPlayDeviceClassForUser(ctx context.Context, userID pgtype.UUID) (*string, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getLatestPlayDeviceClassForUser, userID)
|
||||||
|
var device_class *string
|
||||||
|
err := row.Scan(&device_class)
|
||||||
|
return device_class, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listArtistContextPlayCountsForUser = `-- name: ListArtistContextPlayCountsForUser :many
|
||||||
|
WITH tz AS (
|
||||||
|
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
|
||||||
|
FROM users u WHERE u.id = $1
|
||||||
|
),
|
||||||
|
now_cell AS (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS daypart,
|
||||||
|
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||||||
|
FROM tz
|
||||||
|
),
|
||||||
|
plays AS (
|
||||||
|
SELECT t.artist_id,
|
||||||
|
pe.device_class,
|
||||||
|
CASE
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS daypart,
|
||||||
|
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
CROSS JOIN tz
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.was_skipped = false
|
||||||
|
AND pe.started_at > now() - interval '365 days'
|
||||||
|
)
|
||||||
|
SELECT p.artist_id,
|
||||||
|
count(*) AS total_plays,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE p.daypart = (SELECT daypart FROM now_cell)
|
||||||
|
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
|
||||||
|
AND ($2::text = '' OR p.device_class = $2::text)
|
||||||
|
) AS cell_plays
|
||||||
|
FROM plays p
|
||||||
|
GROUP BY p.artist_id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListArtistContextPlayCountsForUserParams struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
Column2 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListArtistContextPlayCountsForUserRow struct {
|
||||||
|
ArtistID pgtype.UUID
|
||||||
|
TotalPlays int64
|
||||||
|
CellPlays int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-artist completed-play counts split by whether each play falls in the
|
||||||
|
// CURRENT context cell, in the user's local timezone (#1531). The cell is
|
||||||
|
// daypart × weekday-type, optionally narrowed by device class (#1551): when
|
||||||
|
// $2 (the current device) is ”, the device dimension is ignored (identical to
|
||||||
|
// the time-only #1531 behaviour, used by the daily mixes which have no device);
|
||||||
|
// when $2 is set (radio), a play only counts in the cell if its device_class
|
||||||
|
// matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
|
||||||
|
// window bounds cost. Daypart buckets: night [22,5) morning [5,12)
|
||||||
|
// afternoon [12,17) evening [17,22). Weekend = ISO days 6–7 (Sat/Sun).
|
||||||
|
func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, arg ListArtistContextPlayCountsForUserParams) ([]ListArtistContextPlayCountsForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, arg.ID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListArtistContextPlayCountsForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListArtistContextPlayCountsForUserRow
|
||||||
|
if err := rows.Scan(&i.ArtistID, &i.TotalPlays, &i.CellPlays); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
||||||
WITH user_plays AS (
|
WITH user_plays AS (
|
||||||
SELECT t.artist_id, max(pe.started_at) AS last_started
|
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||||||
@@ -585,8 +689,10 @@ SELECT
|
|||||||
(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,
|
||||||
pe.skip_count
|
pe.skip_count,
|
||||||
|
al.release_date AS release_date
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
|
JOIN albums al ON al.id = t.album_id
|
||||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -620,6 +726,7 @@ type LoadRadioCandidatesRow struct {
|
|||||||
LastPlayedAt pgtype.Timestamptz
|
LastPlayedAt pgtype.Timestamptz
|
||||||
PlayCount int64
|
PlayCount int64
|
||||||
SkipCount int64
|
SkipCount int64
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns all tracks except the seed and any played by the user within
|
// Returns all tracks except the seed and any played by the user within
|
||||||
@@ -660,6 +767,7 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
|||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
&i.SkipCount,
|
&i.SkipCount,
|
||||||
|
&i.ReleaseDate,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -754,6 +862,22 @@ taste_overlap AS (
|
|||||||
ORDER BY tpa.weight DESC, t.id
|
ORDER BY tpa.weight DESC, t.id
|
||||||
LIMIT $10
|
LIMIT $10
|
||||||
),
|
),
|
||||||
|
coplay_artists AS (
|
||||||
|
-- Household co-play (#1533): tracks by artists co-played across the
|
||||||
|
-- instance with the seed's artist (source='user_cooccurrence', built by
|
||||||
|
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
||||||
|
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
||||||
|
-- similar_artists since it's artist-level.
|
||||||
|
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
||||||
|
FROM artist_similarity asim
|
||||||
|
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
||||||
|
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||||||
|
WHERE asim.source = 'user_cooccurrence'
|
||||||
|
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
ORDER BY asim.score DESC, random()
|
||||||
|
LIMIT $11
|
||||||
|
),
|
||||||
random_fill AS (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -765,6 +889,7 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
UNION SELECT track_id FROM taste_overlap
|
UNION SELECT track_id FROM taste_overlap
|
||||||
|
UNION SELECT track_id FROM coplay_artists
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
LIMIT $9
|
||||||
@@ -775,6 +900,7 @@ SELECT
|
|||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
pe.skip_count,
|
pe.skip_count,
|
||||||
|
al.release_date AS release_date,
|
||||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||||
FROM (
|
FROM (
|
||||||
SELECT track_id, sim_score FROM lb_similar
|
SELECT track_id, sim_score FROM lb_similar
|
||||||
@@ -782,9 +908,11 @@ FROM (
|
|||||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id
|
||||||
|
JOIN albums al ON al.id = t.album_id
|
||||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT max(started_at) AS last_played_at,
|
SELECT max(started_at) AS last_played_at,
|
||||||
@@ -796,7 +924,8 @@ LEFT JOIN LATERAL (
|
|||||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count
|
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
|
||||||
|
al.release_date
|
||||||
`
|
`
|
||||||
|
|
||||||
type LoadRadioCandidatesV2Params struct {
|
type LoadRadioCandidatesV2Params struct {
|
||||||
@@ -810,6 +939,7 @@ type LoadRadioCandidatesV2Params struct {
|
|||||||
Limit_4 int32
|
Limit_4 int32
|
||||||
Limit_5 int32
|
Limit_5 int32
|
||||||
Limit_6 int32
|
Limit_6 int32
|
||||||
|
Limit_7 int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadRadioCandidatesV2Row struct {
|
type LoadRadioCandidatesV2Row struct {
|
||||||
@@ -818,6 +948,7 @@ type LoadRadioCandidatesV2Row struct {
|
|||||||
LastPlayedAt pgtype.Timestamptz
|
LastPlayedAt pgtype.Timestamptz
|
||||||
PlayCount int64
|
PlayCount int64
|
||||||
SkipCount int64
|
SkipCount int64
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
SimilarityScore interface{}
|
SimilarityScore interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,7 +960,9 @@ type LoadRadioCandidatesV2Row struct {
|
|||||||
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||||
// positively-weighted taste-profile artists, so taste-relevant tracks
|
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||||||
// enter the pool even when the similarity/random arms miss them; scored
|
// enter the pool even when the similarity/random arms miss them; scored
|
||||||
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
|
||||||
|
// $11 coplay_artists K (#1533 — tracks by artists co-played across the
|
||||||
|
// instance with the seed's artist; source='user_cooccurrence').
|
||||||
//
|
//
|
||||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||||
@@ -844,6 +977,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
arg.Limit_4,
|
arg.Limit_4,
|
||||||
arg.Limit_5,
|
arg.Limit_5,
|
||||||
arg.Limit_6,
|
arg.Limit_6,
|
||||||
|
arg.Limit_7,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -874,6 +1008,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
&i.SkipCount,
|
&i.SkipCount,
|
||||||
|
&i.ReleaseDate,
|
||||||
&i.SimilarityScore,
|
&i.SimilarityScore,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -887,20 +1022,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,
|
||||||
@@ -917,6 +1082,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,
|
||||||
@@ -947,13 +1124,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,8 +9,24 @@ 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 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
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||||
@@ -23,6 +39,9 @@ func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
|||||||
&i.EngagementNeutral,
|
&i.EngagementNeutral,
|
||||||
&i.EngagementFull,
|
&i.EngagementFull,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrichedTagScale,
|
||||||
|
&i.EraScale,
|
||||||
|
&i.MoodScale,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -80,7 +99,7 @@ func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]Recommend
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listWeightProfiles = `-- name: ListWeightProfiles :many
|
const listWeightProfiles = `-- name: ListWeightProfiles :many
|
||||||
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile
|
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight FROM recommendation_weight_profiles ORDER BY profile
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
|
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
|
||||||
@@ -103,6 +122,7 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
|
|||||||
&i.SimilarityWeight,
|
&i.SimilarityWeight,
|
||||||
&i.TasteWeight,
|
&i.TasteWeight,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ContextTimeWeight,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -114,15 +134,44 @@ 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,
|
||||||
engagement_hard_skip = $2,
|
engagement_hard_skip = $2,
|
||||||
engagement_neutral = $3,
|
engagement_neutral = $3,
|
||||||
engagement_full = $4,
|
engagement_full = $4,
|
||||||
|
enriched_tag_scale = $5,
|
||||||
|
era_scale = $6,
|
||||||
|
mood_scale = $7,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE singleton = true
|
WHERE singleton = true
|
||||||
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at
|
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateTasteTuningParams struct {
|
type UpdateTasteTuningParams struct {
|
||||||
@@ -130,6 +179,9 @@ type UpdateTasteTuningParams struct {
|
|||||||
EngagementHardSkip float64
|
EngagementHardSkip float64
|
||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
|
EnrichedTagScale float64
|
||||||
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
||||||
@@ -138,6 +190,9 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
|||||||
arg.EngagementHardSkip,
|
arg.EngagementHardSkip,
|
||||||
arg.EngagementNeutral,
|
arg.EngagementNeutral,
|
||||||
arg.EngagementFull,
|
arg.EngagementFull,
|
||||||
|
arg.EnrichedTagScale,
|
||||||
|
arg.EraScale,
|
||||||
|
arg.MoodScale,
|
||||||
)
|
)
|
||||||
var i TasteTuning
|
var i TasteTuning
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -147,35 +202,40 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
|||||||
&i.EngagementNeutral,
|
&i.EngagementNeutral,
|
||||||
&i.EngagementFull,
|
&i.EngagementFull,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrichedTagScale,
|
||||||
|
&i.EraScale,
|
||||||
|
&i.MoodScale,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateWeightProfile = `-- name: UpdateWeightProfile :one
|
const updateWeightProfile = `-- name: UpdateWeightProfile :one
|
||||||
UPDATE recommendation_weight_profiles
|
UPDATE recommendation_weight_profiles
|
||||||
SET base_weight = $2,
|
SET base_weight = $2,
|
||||||
like_boost = $3,
|
like_boost = $3,
|
||||||
recency_weight = $4,
|
recency_weight = $4,
|
||||||
skip_penalty = $5,
|
skip_penalty = $5,
|
||||||
jitter_magnitude = $6,
|
jitter_magnitude = $6,
|
||||||
context_weight = $7,
|
context_weight = $7,
|
||||||
similarity_weight = $8,
|
similarity_weight = $8,
|
||||||
taste_weight = $9,
|
taste_weight = $9,
|
||||||
updated_at = now()
|
context_time_weight = $10,
|
||||||
|
updated_at = now()
|
||||||
WHERE profile = $1
|
WHERE profile = $1
|
||||||
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at
|
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateWeightProfileParams struct {
|
type UpdateWeightProfileParams struct {
|
||||||
Profile string
|
Profile string
|
||||||
BaseWeight float64
|
BaseWeight float64
|
||||||
LikeBoost float64
|
LikeBoost float64
|
||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
SimilarityWeight float64
|
SimilarityWeight float64
|
||||||
TasteWeight float64
|
TasteWeight float64
|
||||||
|
ContextTimeWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
|
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
|
||||||
@@ -189,6 +249,7 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
|||||||
arg.ContextWeight,
|
arg.ContextWeight,
|
||||||
arg.SimilarityWeight,
|
arg.SimilarityWeight,
|
||||||
arg.TasteWeight,
|
arg.TasteWeight,
|
||||||
|
arg.ContextTimeWeight,
|
||||||
)
|
)
|
||||||
var i RecommendationWeightProfile
|
var i RecommendationWeightProfile
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -202,15 +263,35 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
|||||||
&i.SimilarityWeight,
|
&i.SimilarityWeight,
|
||||||
&i.TasteWeight,
|
&i.TasteWeight,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ContextTimeWeight,
|
||||||
)
|
)
|
||||||
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,
|
||||||
engagement_neutral, engagement_full
|
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||||
) VALUES (true, $1, $2, $3, $4)
|
mood_scale
|
||||||
|
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (singleton) DO NOTHING
|
ON CONFLICT (singleton) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -219,6 +300,9 @@ type UpsertTasteTuningDefaultsParams struct {
|
|||||||
EngagementHardSkip float64
|
EngagementHardSkip float64
|
||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
|
EnrichedTagScale float64
|
||||||
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
||||||
@@ -227,6 +311,9 @@ func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTaste
|
|||||||
arg.EngagementHardSkip,
|
arg.EngagementHardSkip,
|
||||||
arg.EngagementNeutral,
|
arg.EngagementNeutral,
|
||||||
arg.EngagementFull,
|
arg.EngagementFull,
|
||||||
|
arg.EnrichedTagScale,
|
||||||
|
arg.EraScale,
|
||||||
|
arg.MoodScale,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -235,21 +322,23 @@ const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
|
|||||||
|
|
||||||
INSERT INTO recommendation_weight_profiles (
|
INSERT INTO recommendation_weight_profiles (
|
||||||
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||||
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
jitter_magnitude, context_weight, similarity_weight, taste_weight,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
context_time_weight
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (profile) DO NOTHING
|
ON CONFLICT (profile) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertWeightProfileDefaultsParams struct {
|
type UpsertWeightProfileDefaultsParams struct {
|
||||||
Profile string
|
Profile string
|
||||||
BaseWeight float64
|
BaseWeight float64
|
||||||
LikeBoost float64
|
LikeBoost float64
|
||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
SimilarityWeight float64
|
SimilarityWeight float64
|
||||||
TasteWeight float64
|
TasteWeight float64
|
||||||
|
ContextTimeWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recommendation tuning lab queries (#1250). Seeding happens via the
|
// Recommendation tuning lab queries (#1250). Seeding happens via the
|
||||||
@@ -267,6 +356,7 @@ func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWei
|
|||||||
arg.ContextWeight,
|
arg.ContextWeight,
|
||||||
arg.SimilarityWeight,
|
arg.SimilarityWeight,
|
||||||
arg.TasteWeight,
|
arg.TasteWeight,
|
||||||
|
arg.ContextTimeWeight,
|
||||||
)
|
)
|
||||||
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
|
||||||
|
}
|
||||||
@@ -20,6 +20,24 @@ func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID p
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteTasteProfileErasForUser = `-- name: DeleteTasteProfileErasForUser :exec
|
||||||
|
DELETE FROM taste_profile_eras WHERE user_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteTasteProfileErasForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteTasteProfileErasForUser, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteTasteProfileMoodsForUser = `-- name: DeleteTasteProfileMoodsForUser :exec
|
||||||
|
DELETE FROM taste_profile_moods WHERE user_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) DeleteTasteProfileMoodsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteTasteProfileMoodsForUser, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
|
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
|
||||||
DELETE FROM taste_profile_tags WHERE user_id = $1
|
DELETE FROM taste_profile_tags WHERE user_id = $1
|
||||||
`
|
`
|
||||||
@@ -45,6 +63,38 @@ func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteP
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const insertTasteProfileEra = `-- name: InsertTasteProfileEra :exec
|
||||||
|
INSERT INTO taste_profile_eras (user_id, era, weight)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertTasteProfileEraParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Era string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertTasteProfileEra(ctx context.Context, arg InsertTasteProfileEraParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertTasteProfileEra, arg.UserID, arg.Era, arg.Weight)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertTasteProfileMood = `-- name: InsertTasteProfileMood :exec
|
||||||
|
INSERT INTO taste_profile_moods (user_id, mood, weight)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertTasteProfileMoodParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Mood string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) InsertTasteProfileMood(ctx context.Context, arg InsertTasteProfileMoodParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertTasteProfileMood, arg.UserID, arg.Mood, arg.Weight)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
|
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
|
||||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -87,21 +137,23 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
||||||
SELECT t.id AS track_id, t.artist_id, t.genre
|
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
|
||||||
FROM general_likes gl
|
FROM general_likes gl
|
||||||
JOIN tracks t ON t.id = gl.track_id
|
JOIN tracks t ON t.id = gl.track_id
|
||||||
|
JOIN albums a ON a.id = t.album_id
|
||||||
WHERE gl.user_id = $1
|
WHERE gl.user_id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListLikedTrackTasteInputsForUserRow struct {
|
type ListLikedTrackTasteInputsForUserRow struct {
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
Genre *string
|
Genre *string
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
}
|
}
|
||||||
|
|
||||||
// (track_id, artist_id, genre) for each track the user has explicitly
|
// (track_id, artist_id, genre, release_date) for each track the user has
|
||||||
// liked. Feeds the track-like bonus into the liked track's artist and
|
// explicitly liked. Feeds the track-like bonus into the liked track's artist,
|
||||||
// tags; track_id keys the enriched track_tags lookup (#1490).
|
// tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
|
||||||
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -111,7 +163,12 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
|||||||
var items []ListLikedTrackTasteInputsForUserRow
|
var items []ListLikedTrackTasteInputsForUserRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListLikedTrackTasteInputsForUserRow
|
var i ListLikedTrackTasteInputsForUserRow
|
||||||
if err := rows.Scan(&i.TrackID, &i.ArtistID, &i.Genre); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.TrackID,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.Genre,
|
||||||
|
&i.ReleaseDate,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -128,6 +185,7 @@ SELECT
|
|||||||
t.id AS track_id,
|
t.id AS track_id,
|
||||||
t.artist_id,
|
t.artist_id,
|
||||||
t.genre,
|
t.genre,
|
||||||
|
a.release_date,
|
||||||
LEAST(GREATEST(
|
LEAST(GREATEST(
|
||||||
COALESCE(pe.completion_ratio,
|
COALESCE(pe.completion_ratio,
|
||||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
||||||
@@ -135,6 +193,7 @@ SELECT
|
|||||||
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
JOIN albums a ON a.id = t.album_id
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -149,11 +208,12 @@ type ListPlayEngagementInputsForUserParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListPlayEngagementInputsForUserRow struct {
|
type ListPlayEngagementInputsForUserRow struct {
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
Genre *string
|
Genre *string
|
||||||
Completion float64
|
ReleaseDate pgtype.Date
|
||||||
AgeDays float64
|
Completion float64
|
||||||
|
AgeDays float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
|
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
|
||||||
@@ -162,7 +222,8 @@ type ListPlayEngagementInputsForUserRow struct {
|
|||||||
// One row per play in the decay-relevant window. completion is the play's
|
// One row per play in the decay-relevant window. completion is the play's
|
||||||
// completion ratio (precomputed column when present, else duration_played /
|
// completion ratio (precomputed column when present, else duration_played /
|
||||||
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||||
// is split into tags in Go. Quarantined tracks are excluded.
|
// is split into tags in Go; release_date derives the decade for the era
|
||||||
|
// facet (#1530). Quarantined tracks are excluded.
|
||||||
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
|
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
|
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -176,6 +237,7 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
|
|||||||
&i.TrackID,
|
&i.TrackID,
|
||||||
&i.ArtistID,
|
&i.ArtistID,
|
||||||
&i.Genre,
|
&i.Genre,
|
||||||
|
&i.ReleaseDate,
|
||||||
&i.Completion,
|
&i.Completion,
|
||||||
&i.AgeDays,
|
&i.AgeDays,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -228,6 +290,84 @@ func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTa
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listTasteProfileErasForUser = `-- name: ListTasteProfileErasForUser :many
|
||||||
|
SELECT era, weight
|
||||||
|
FROM taste_profile_eras
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY weight DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListTasteProfileErasForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListTasteProfileErasForUserRow struct {
|
||||||
|
Era string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-weighted taste eras (#1530); consumed by the scorer's era term.
|
||||||
|
func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTasteProfileErasForUserParams) ([]ListTasteProfileErasForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTasteProfileErasForUser, arg.UserID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTasteProfileErasForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTasteProfileErasForUserRow
|
||||||
|
if err := rows.Scan(&i.Era, &i.Weight); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTasteProfileMoodsForUser = `-- name: ListTasteProfileMoodsForUser :many
|
||||||
|
SELECT mood, weight
|
||||||
|
FROM taste_profile_moods
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY weight DESC
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListTasteProfileMoodsForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListTasteProfileMoodsForUserRow struct {
|
||||||
|
Mood string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-weighted taste moods (#1534); consumed by the scorer's mood term.
|
||||||
|
func (q *Queries) ListTasteProfileMoodsForUser(ctx context.Context, arg ListTasteProfileMoodsForUserParams) ([]ListTasteProfileMoodsForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTasteProfileMoodsForUser, arg.UserID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTasteProfileMoodsForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTasteProfileMoodsForUserRow
|
||||||
|
if err := rows.Scan(&i.Mood, &i.Weight); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
|
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
|
||||||
SELECT tag, weight
|
SELECT tag, weight
|
||||||
FROM taste_profile_tags
|
FROM taste_profile_tags
|
||||||
|
|||||||
@@ -110,9 +110,43 @@ func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayed
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listTrackTagsForTracks = `-- name: ListTrackTagsForTracks :many
|
||||||
|
SELECT tt.track_id, tt.tag
|
||||||
|
FROM track_tags tt
|
||||||
|
WHERE tt.track_id = ANY($1::uuid[])
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListTrackTagsForTracksRow struct {
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enriched tags for a set of candidate tracks (#1534), so the scorer can
|
||||||
|
// derive each candidate's mood buckets at scoring time. One row per (track,
|
||||||
|
// tag); the caller filters to mood words via the internal/mood vocabulary.
|
||||||
|
func (q *Queries) ListTrackTagsForTracks(ctx context.Context, dollar_1 []pgtype.UUID) ([]ListTrackTagsForTracksRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTrackTagsForTracks, dollar_1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTrackTagsForTracksRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTrackTagsForTracksRow
|
||||||
|
if err := rows.Scan(&i.TrackID, &i.Tag); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listTracksMissingTags = `-- name: ListTracksMissingTags :many
|
const listTracksMissingTags = `-- name: ListTracksMissingTags :many
|
||||||
|
|
||||||
SELECT t.id, t.mbid, t.title, a.name AS artist_name
|
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN artists a ON a.id = t.artist_id
|
JOIN artists a ON a.id = t.artist_id
|
||||||
WHERE t.tag_source IS NULL
|
WHERE t.tag_source IS NULL
|
||||||
@@ -131,6 +165,7 @@ type ListTracksMissingTagsRow struct {
|
|||||||
Mbid *string
|
Mbid *string
|
||||||
Title string
|
Title string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
|
ArtistMbid *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track-tag enrichment queries (milestone #160, #1490). The tag enricher
|
// Track-tag enrichment queries (milestone #160, #1490). The tag enricher
|
||||||
@@ -140,7 +175,8 @@ type ListTracksMissingTagsRow struct {
|
|||||||
// Tracks eligible for tag enrichment: never processed (tag_source NULL)
|
// Tracks eligible for tag enrichment: never processed (tag_source NULL)
|
||||||
// or previously settled 'none' under an older provider version. Returns
|
// or previously settled 'none' under an older provider version. Returns
|
||||||
// the fields the provider chain needs — recording MBID (nullable) for
|
// the fields the provider chain needs — recording MBID (nullable) for
|
||||||
// keyed lookups, plus title + artist name for name-based fallback.
|
// keyed lookups, title + artist name for name-based fallback, and the
|
||||||
|
// artist MBID (nullable) for MusicBrainz's artist-tag fallback (#1519).
|
||||||
// $1 = current tag_sources_version, $2 = limit.
|
// $1 = current tag_sources_version, $2 = limit.
|
||||||
func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) {
|
func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listTracksMissingTags, arg.TagSourcesVersion, arg.Limit)
|
rows, err := q.db.Query(ctx, listTracksMissingTags, arg.TagSourcesVersion, arg.Limit)
|
||||||
@@ -156,6 +192,7 @@ func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissi
|
|||||||
&i.Mbid,
|
&i.Mbid,
|
||||||
&i.Title,
|
&i.Title,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
|
&i.ArtistMbid,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Reverse 0044_taste_tuning_enriched_tag_scale.up.sql.
|
||||||
|
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS enriched_tag_scale;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Expose EnrichedTagScale in the tuning lab (#1520). Adds the enriched-tag
|
||||||
|
-- weight knob to the taste_tuning singleton so operators can dial how much
|
||||||
|
-- folksonomy tags (#1490) count vs raw ID3 genre. DEFAULT 0.5 backfills the
|
||||||
|
-- existing row and matches taste.DefaultConfig().EnrichedTagScale.
|
||||||
|
ALTER TABLE taste_tuning
|
||||||
|
ADD COLUMN enriched_tag_scale double precision NOT NULL DEFAULT 0.5;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS era_scale;
|
||||||
|
DROP TABLE IF EXISTS taste_profile_eras;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 0045_taste_profile_eras.up.sql — era/decade taste facet (#1530,
|
||||||
|
-- milestone #160 Opt 2). A third taste facet alongside artists + tags:
|
||||||
|
-- signed weights over decade buckets ("1990s") derived from
|
||||||
|
-- albums.release_date, rebuilt daily by internal/taste next to the
|
||||||
|
-- artist/tag facets. Weight is a signed float — positive = drawn to
|
||||||
|
-- that era, negative = passively avoided; magnitude reflects decayed
|
||||||
|
-- engagement. Consumed by the recommendation scorer's taste match.
|
||||||
|
-- Mirrors taste_profile_tags: CASCADE on user delete, indexed by
|
||||||
|
-- (user_id, weight DESC) so "top eras" reads stay cheap.
|
||||||
|
CREATE TABLE taste_profile_eras (
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
era text NOT NULL,
|
||||||
|
weight double precision NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, era)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX taste_profile_eras_user_weight_idx
|
||||||
|
ON taste_profile_eras (user_id, weight DESC);
|
||||||
|
|
||||||
|
-- Era-facet build knob, tunable in the lab (mirrors enriched_tag_scale,
|
||||||
|
-- #1520): scales how strongly a decade-play imprints on the profile.
|
||||||
|
-- DEFAULT 0.5 backfills the existing singleton and matches
|
||||||
|
-- taste.DefaultConfig().EraScale; 0 disables the era facet entirely.
|
||||||
|
ALTER TABLE taste_tuning
|
||||||
|
ADD COLUMN era_scale double precision NOT NULL DEFAULT 0.5;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE recommendation_weight_profiles DROP COLUMN IF EXISTS context_time_weight;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- 0046_context_time_weight.up.sql — time-of-day/weekday context conditioning
|
||||||
|
-- (#1531, milestone #160 Opt 3). Adds a per-profile scoring weight for the new
|
||||||
|
-- context-affinity term: how strongly a candidate is boosted when its artist's
|
||||||
|
-- play history concentrates in the current daypart × weekday-type cell (in the
|
||||||
|
-- user's local timezone). Mirrors the other ScoringWeights columns.
|
||||||
|
--
|
||||||
|
-- DEFAULT 1.0 backfills both existing profile rows to a modest on-value; the
|
||||||
|
-- Go ShippedRadioWeights/ShippedDailyMixWeights carry the same 1.0 so fresh
|
||||||
|
-- installs seed identically. Reconcile only seeds MISSING rows (ON CONFLICT DO
|
||||||
|
-- NOTHING), so existing rows rely on this DEFAULT until an operator resets.
|
||||||
|
ALTER TABLE recommendation_weight_profiles
|
||||||
|
ADD COLUMN context_time_weight double precision NOT NULL DEFAULT 1.0;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS mood_scale;
|
||||||
|
DROP TABLE IF EXISTS taste_profile_moods;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- 0047_taste_profile_moods.up.sql — mood taste facet (#1534, milestone #160
|
||||||
|
-- Opt 2b). A fourth taste facet alongside artists + tags + eras: signed weights
|
||||||
|
-- over canonical mood buckets (melancholic / energetic / chill / …) derived
|
||||||
|
-- from a track's enriched folksonomy tags (#1490) via the internal/mood
|
||||||
|
-- vocabulary, rebuilt daily. Weight is a signed float — positive = drawn to
|
||||||
|
-- that mood, negative = passively avoided. Coverage is partial (grows with tag
|
||||||
|
-- enrichment), so it's a supplement, not a foundation. Mirrors
|
||||||
|
-- taste_profile_tags: CASCADE on user delete, indexed by (user_id, weight DESC).
|
||||||
|
CREATE TABLE taste_profile_moods (
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
mood text NOT NULL,
|
||||||
|
weight double precision NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, mood)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX taste_profile_moods_user_weight_idx
|
||||||
|
ON taste_profile_moods (user_id, weight DESC);
|
||||||
|
|
||||||
|
-- Mood-facet build knob, tunable in the lab (mirrors era_scale / enriched_tag_
|
||||||
|
-- scale): scales how strongly a mood-tagged play imprints on the profile.
|
||||||
|
-- DEFAULT 0.5 backfills the existing singleton and matches
|
||||||
|
-- taste.DefaultConfig().MoodScale; 0 disables the mood facet entirely.
|
||||||
|
ALTER TABLE taste_tuning
|
||||||
|
ADD COLUMN mood_scale double precision NOT NULL DEFAULT 0.5;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE play_events DROP COLUMN IF EXISTS device_class;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- 0048_play_events_device_class.up.sql — device-class context conditioning
|
||||||
|
-- (#1551, milestone #160 Opt 3b). Records which class of device a play came
|
||||||
|
-- from (mobile / web / …), sent by the client on play-start and normalized
|
||||||
|
-- server-side. Extends the #1531 time-of-day/weekday affinity cell to
|
||||||
|
-- (daypart × weekday × device) for the radio path. Nullable + no CHECK: values
|
||||||
|
-- are normalized in Go (avoids a CHECK-migration each time a client class is
|
||||||
|
-- added, rule #36), and historical rows stay NULL → excluded from the device
|
||||||
|
-- dimension (a graceful cold-start; the facet ramps as new plays land).
|
||||||
|
ALTER TABLE play_events ADD COLUMN device_class text;
|
||||||
@@ -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,103 @@
|
|||||||
|
-- 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.
|
||||||
|
|
||||||
|
-- name: ListCandidateArtistsMissingTags :many
|
||||||
|
-- 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.
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- name: DeleteCandidateArtistTags :exec
|
||||||
|
-- Clear a candidate's cached tags before rewriting (atomic replace by the
|
||||||
|
-- caller, same shape as DeleteTrackTags).
|
||||||
|
DELETE FROM candidate_artist_tags WHERE candidate_mbid = $1;
|
||||||
|
|
||||||
|
-- name: InsertCandidateArtistTag :exec
|
||||||
|
-- Upsert one (candidate, tag); keep the stronger weight when two providers
|
||||||
|
-- agree on a tag with different folksonomy strengths.
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- name: SetCandidateArtistTagState :exec
|
||||||
|
-- Stamp the enrichment outcome so the drainer skips settled candidates.
|
||||||
|
-- $2 = 'musicbrainz' | 'lastfm' | 'mixed' | 'none', $3 = current version.
|
||||||
|
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();
|
||||||
|
|
||||||
|
-- name: ListCandidateArtistTagsForMbids :many
|
||||||
|
-- Cached tags for a set of candidates, for slice 6's taste-overlap ranking.
|
||||||
|
-- One row per (candidate, tag).
|
||||||
|
SELECT candidate_mbid, tag, weight
|
||||||
|
FROM candidate_artist_tags
|
||||||
|
WHERE candidate_mbid = ANY($1::text[]);
|
||||||
|
|
||||||
|
-- name: CountCandidateArtistTagCoverage :one
|
||||||
|
-- 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.
|
||||||
|
SELECT count(*)::bigint AS processed,
|
||||||
|
count(*) FILTER (WHERE tag_source <> 'none')::bigint AS with_tags
|
||||||
|
FROM candidate_artist_tag_state;
|
||||||
|
|
||||||
|
-- name: GcDeleteOrphanedCandidateArtistTags :execrows
|
||||||
|
-- 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.
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- name: GcDeleteOrphanedCandidateArtistTagState :execrows
|
||||||
|
-- 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.
|
||||||
|
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);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
-- Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a
|
||||||
|
-- single shared-library, multi-user server (no per-user library ACL), so the
|
||||||
|
-- "household" is the whole instance's user set. These queries recompute
|
||||||
|
-- artist–artist co-occurrence edges from play_events and store them in
|
||||||
|
-- artist_similarity under the pre-provisioned source = 'user_cooccurrence'
|
||||||
|
-- (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then
|
||||||
|
-- re-INSERTs so edges that fall below threshold disappear. Single-user servers
|
||||||
|
-- produce no edges (the >= 2 co-player gate), so the arm is empty there.
|
||||||
|
|
||||||
|
-- name: DeleteArtistCoplayEdges :exec
|
||||||
|
DELETE FROM artist_similarity WHERE source = 'user_cooccurrence';
|
||||||
|
|
||||||
|
-- name: InsertArtistCoplayEdges :exec
|
||||||
|
-- Two artists are "co-played" when the same users play both. score is the
|
||||||
|
-- Jaccard of their distinct-player sets — coplayers / (playersA + playersB −
|
||||||
|
-- coplayers) in (0,1] — which controls for globally-popular artists (an artist
|
||||||
|
-- everyone plays would otherwise co-occur with everything). Only pairs with
|
||||||
|
-- >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak
|
||||||
|
-- edges and self-limits hub artists, whose large denominators drag their
|
||||||
|
-- Jaccard down) are kept. Completed plays only, 365-day window.
|
||||||
|
WITH user_artists AS (
|
||||||
|
SELECT DISTINCT pe.user_id, t.artist_id
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.was_skipped = false
|
||||||
|
AND pe.started_at > now() - interval '365 days'
|
||||||
|
),
|
||||||
|
artist_players AS (
|
||||||
|
SELECT artist_id, count(*)::float8 AS players
|
||||||
|
FROM user_artists
|
||||||
|
GROUP BY artist_id
|
||||||
|
),
|
||||||
|
pairs AS (
|
||||||
|
SELECT ua.artist_id AS a_id,
|
||||||
|
ub.artist_id AS b_id,
|
||||||
|
count(*)::float8 AS coplayers
|
||||||
|
FROM user_artists ua
|
||||||
|
JOIN user_artists ub
|
||||||
|
ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id
|
||||||
|
GROUP BY ua.artist_id, ub.artist_id
|
||||||
|
HAVING count(*) >= 2
|
||||||
|
),
|
||||||
|
scored AS (
|
||||||
|
SELECT p.a_id, p.b_id,
|
||||||
|
p.coplayers / (pa.players + pb.players - p.coplayers) AS score
|
||||||
|
FROM pairs p
|
||||||
|
JOIN artist_players pa ON pa.artist_id = p.a_id
|
||||||
|
JOIN artist_players pb ON pb.artist_id = p.b_id
|
||||||
|
)
|
||||||
|
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
|
||||||
|
SELECT a_id, b_id, score, 'user_cooccurrence', now()
|
||||||
|
FROM scored
|
||||||
|
WHERE score >= $1
|
||||||
|
ON CONFLICT (artist_a_id, artist_b_id, source)
|
||||||
|
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at;
|
||||||
@@ -28,8 +28,10 @@ LIMIT 1;
|
|||||||
-- found (with a stamped kind) in the user's live snapshot for that
|
-- found (with a stamped kind) in the user's live snapshot for that
|
||||||
-- variant at ingestion time (#1249, generalized in #1270).
|
-- variant at ingestion time (#1249, generalized in #1270).
|
||||||
INSERT INTO play_events (
|
INSERT INTO play_events (
|
||||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text)
|
device_class
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text,
|
||||||
|
sqlc.narg(device_class)::text)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetSystemPickKindForTrack :one
|
-- name: GetSystemPickKindForTrack :one
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ SELECT
|
|||||||
(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,
|
||||||
pe.skip_count
|
pe.skip_count,
|
||||||
|
al.release_date AS release_date
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
|
JOIN albums al ON al.id = t.album_id
|
||||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -40,7 +42,9 @@ WHERE t.id <> $2
|
|||||||
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||||
-- positively-weighted taste-profile artists, so taste-relevant tracks
|
-- positively-weighted taste-profile artists, so taste-relevant tracks
|
||||||
-- enter the pool even when the similarity/random arms miss them; scored
|
-- enter the pool even when the similarity/random arms miss them; scored
|
||||||
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
|
||||||
|
-- $11 coplay_artists K (#1533 — tracks by artists co-played across the
|
||||||
|
-- instance with the seed's artist; source='user_cooccurrence').
|
||||||
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
|
|
||||||
WITH
|
WITH
|
||||||
@@ -124,6 +128,22 @@ taste_overlap AS (
|
|||||||
ORDER BY tpa.weight DESC, t.id
|
ORDER BY tpa.weight DESC, t.id
|
||||||
LIMIT $10
|
LIMIT $10
|
||||||
),
|
),
|
||||||
|
coplay_artists AS (
|
||||||
|
-- Household co-play (#1533): tracks by artists co-played across the
|
||||||
|
-- instance with the seed's artist (source='user_cooccurrence', built by
|
||||||
|
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
||||||
|
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
||||||
|
-- similar_artists since it's artist-level.
|
||||||
|
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
||||||
|
FROM artist_similarity asim
|
||||||
|
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
||||||
|
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||||||
|
WHERE asim.source = 'user_cooccurrence'
|
||||||
|
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
ORDER BY asim.score DESC, random()
|
||||||
|
LIMIT $11
|
||||||
|
),
|
||||||
random_fill AS (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -135,6 +155,7 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
UNION SELECT track_id FROM taste_overlap
|
UNION SELECT track_id FROM taste_overlap
|
||||||
|
UNION SELECT track_id FROM coplay_artists
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
LIMIT $9
|
||||||
@@ -145,6 +166,7 @@ SELECT
|
|||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
pe.skip_count,
|
pe.skip_count,
|
||||||
|
al.release_date AS release_date,
|
||||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||||
FROM (
|
FROM (
|
||||||
SELECT track_id, sim_score FROM lb_similar
|
SELECT track_id, sim_score FROM lb_similar
|
||||||
@@ -152,9 +174,11 @@ FROM (
|
|||||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id
|
||||||
|
JOIN albums al ON al.id = t.album_id
|
||||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT max(started_at) AS last_played_at,
|
SELECT max(started_at) AS last_played_at,
|
||||||
@@ -166,30 +190,135 @@ LEFT JOIN LATERAL (
|
|||||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count;
|
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
|
||||||
|
al.release_date;
|
||||||
|
|
||||||
|
-- name: ListArtistContextPlayCountsForUser :many
|
||||||
|
-- Per-artist completed-play counts split by whether each play falls in the
|
||||||
|
-- CURRENT context cell, in the user's local timezone (#1531). The cell is
|
||||||
|
-- daypart × weekday-type, optionally narrowed by device class (#1551): when
|
||||||
|
-- $2 (the current device) is '', the device dimension is ignored (identical to
|
||||||
|
-- the time-only #1531 behaviour, used by the daily mixes which have no device);
|
||||||
|
-- when $2 is set (radio), a play only counts in the cell if its device_class
|
||||||
|
-- matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
|
||||||
|
-- window bounds cost. Daypart buckets: night [22,5) morning [5,12)
|
||||||
|
-- afternoon [12,17) evening [17,22). Weekend = ISO days 6–7 (Sat/Sun).
|
||||||
|
WITH tz AS (
|
||||||
|
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
|
||||||
|
FROM users u WHERE u.id = $1
|
||||||
|
),
|
||||||
|
now_cell AS (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS daypart,
|
||||||
|
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||||||
|
FROM tz
|
||||||
|
),
|
||||||
|
plays AS (
|
||||||
|
SELECT t.artist_id,
|
||||||
|
pe.device_class,
|
||||||
|
CASE
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS daypart,
|
||||||
|
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
CROSS JOIN tz
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.was_skipped = false
|
||||||
|
AND pe.started_at > now() - interval '365 days'
|
||||||
|
)
|
||||||
|
SELECT p.artist_id,
|
||||||
|
count(*) AS total_plays,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE p.daypart = (SELECT daypart FROM now_cell)
|
||||||
|
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
|
||||||
|
AND ($2::text = '' OR p.device_class = $2::text)
|
||||||
|
) AS cell_plays
|
||||||
|
FROM plays p
|
||||||
|
GROUP BY p.artist_id;
|
||||||
|
|
||||||
|
-- name: GetLatestPlayDeviceClassForUser :one
|
||||||
|
-- The device_class of the user's most recent play (#1551), used as the
|
||||||
|
-- "current device" for radio context conditioning. NULL when the latest play
|
||||||
|
-- predates device capture or came from a client that didn't send one.
|
||||||
|
SELECT device_class
|
||||||
|
FROM play_events
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: SuggestArtistsForUser :many
|
-- name: SuggestArtistsForUser :many
|
||||||
-- 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
|
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||||
-- aggregates per candidate, returning the top-3 contributing seeds for
|
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||||||
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
|
-- from stops contributing instead of accumulating forever.
|
||||||
WITH seeds AS (
|
-- 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.
|
||||||
|
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,
|
||||||
@@ -206,6 +335,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,
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
-- doesn't exist yet. Never overwrites operator-tuned values.
|
-- doesn't exist yet. Never overwrites operator-tuned values.
|
||||||
INSERT INTO recommendation_weight_profiles (
|
INSERT INTO recommendation_weight_profiles (
|
||||||
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||||
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
jitter_magnitude, context_weight, similarity_weight, taste_weight,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
context_time_weight
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (profile) DO NOTHING;
|
ON CONFLICT (profile) DO NOTHING;
|
||||||
|
|
||||||
-- name: ListWeightProfiles :many
|
-- name: ListWeightProfiles :many
|
||||||
@@ -15,23 +16,25 @@ SELECT * FROM recommendation_weight_profiles ORDER BY profile;
|
|||||||
|
|
||||||
-- name: UpdateWeightProfile :one
|
-- name: UpdateWeightProfile :one
|
||||||
UPDATE recommendation_weight_profiles
|
UPDATE recommendation_weight_profiles
|
||||||
SET base_weight = $2,
|
SET base_weight = $2,
|
||||||
like_boost = $3,
|
like_boost = $3,
|
||||||
recency_weight = $4,
|
recency_weight = $4,
|
||||||
skip_penalty = $5,
|
skip_penalty = $5,
|
||||||
jitter_magnitude = $6,
|
jitter_magnitude = $6,
|
||||||
context_weight = $7,
|
context_weight = $7,
|
||||||
similarity_weight = $8,
|
similarity_weight = $8,
|
||||||
taste_weight = $9,
|
taste_weight = $9,
|
||||||
updated_at = now()
|
context_time_weight = $10,
|
||||||
|
updated_at = now()
|
||||||
WHERE profile = $1
|
WHERE profile = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpsertTasteTuningDefaults :exec
|
-- 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,
|
||||||
engagement_neutral, engagement_full
|
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||||
) VALUES (true, $1, $2, $3, $4)
|
mood_scale
|
||||||
|
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (singleton) DO NOTHING;
|
ON CONFLICT (singleton) DO NOTHING;
|
||||||
|
|
||||||
-- name: GetTasteTuning :one
|
-- name: GetTasteTuning :one
|
||||||
@@ -43,10 +46,31 @@ UPDATE taste_tuning
|
|||||||
engagement_hard_skip = $2,
|
engagement_hard_skip = $2,
|
||||||
engagement_neutral = $3,
|
engagement_neutral = $3,
|
||||||
engagement_full = $4,
|
engagement_full = $4,
|
||||||
|
enriched_tag_scale = $5,
|
||||||
|
era_scale = $6,
|
||||||
|
mood_scale = $7,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE singleton = true
|
WHERE singleton = true
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpsertDiscoverTuningDefaults :exec
|
||||||
|
-- Boot reconcile for the Discover scope (#2377). Never overwrites
|
||||||
|
-- operator-tuned values, same contract as the other two.
|
||||||
|
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
|
||||||
|
VALUES (true, $1, $2)
|
||||||
|
ON CONFLICT (singleton) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: GetDiscoverTuning :one
|
||||||
|
SELECT * FROM discover_tuning WHERE singleton = true;
|
||||||
|
|
||||||
|
-- name: UpdateDiscoverTuning :one
|
||||||
|
UPDATE discover_tuning
|
||||||
|
SET tag_overlap_weight = $1,
|
||||||
|
snooze_days = $2,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE singleton = true
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: InsertTuningAudit :exec
|
-- name: InsertTuningAudit :exec
|
||||||
-- changes is a jsonb array of {field, old, new} objects.
|
-- changes is a jsonb array of {field, old, new} objects.
|
||||||
INSERT INTO recommendation_tuning_audit (scope, action, changes)
|
INSERT INTO recommendation_tuning_audit (scope, action, changes)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- 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.
|
||||||
|
|
||||||
|
-- name: SnoozeSuggestion :exec
|
||||||
|
-- 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.
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- name: UnsnoozeSuggestion :execrows
|
||||||
|
-- Row count is returned so the handler can 404 an MBID that was never
|
||||||
|
-- snoozed rather than reporting success for a no-op.
|
||||||
|
DELETE FROM suggestion_snoozes
|
||||||
|
WHERE user_id = $1 AND candidate_mbid = $2;
|
||||||
|
|
||||||
|
-- name: ListActiveSuggestionSnoozes :many
|
||||||
|
-- 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.
|
||||||
|
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;
|
||||||
|
|
||||||
|
-- name: GcDeleteExpiredSuggestionSnoozes :execrows
|
||||||
|
-- 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.
|
||||||
|
DELETE FROM suggestion_snoozes WHERE snoozed_until < now();
|
||||||
@@ -6,11 +6,13 @@
|
|||||||
-- One row per play in the decay-relevant window. completion is the play's
|
-- One row per play in the decay-relevant window. completion is the play's
|
||||||
-- completion ratio (precomputed column when present, else duration_played /
|
-- completion ratio (precomputed column when present, else duration_played /
|
||||||
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||||
-- is split into tags in Go. Quarantined tracks are excluded.
|
-- is split into tags in Go; release_date derives the decade for the era
|
||||||
|
-- facet (#1530). Quarantined tracks are excluded.
|
||||||
SELECT
|
SELECT
|
||||||
t.id AS track_id,
|
t.id AS track_id,
|
||||||
t.artist_id,
|
t.artist_id,
|
||||||
t.genre,
|
t.genre,
|
||||||
|
a.release_date,
|
||||||
LEAST(GREATEST(
|
LEAST(GREATEST(
|
||||||
COALESCE(pe.completion_ratio,
|
COALESCE(pe.completion_ratio,
|
||||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
||||||
@@ -18,6 +20,7 @@ SELECT
|
|||||||
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
JOIN albums a ON a.id = t.album_id
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -26,12 +29,13 @@ WHERE pe.user_id = $1
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- name: ListLikedTrackTasteInputsForUser :many
|
-- name: ListLikedTrackTasteInputsForUser :many
|
||||||
-- (track_id, artist_id, genre) for each track the user has explicitly
|
-- (track_id, artist_id, genre, release_date) for each track the user has
|
||||||
-- liked. Feeds the track-like bonus into the liked track's artist and
|
-- explicitly liked. Feeds the track-like bonus into the liked track's artist,
|
||||||
-- tags; track_id keys the enriched track_tags lookup (#1490).
|
-- tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
|
||||||
SELECT t.id AS track_id, t.artist_id, t.genre
|
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
|
||||||
FROM general_likes gl
|
FROM general_likes gl
|
||||||
JOIN tracks t ON t.id = gl.track_id
|
JOIN tracks t ON t.id = gl.track_id
|
||||||
|
JOIN albums a ON a.id = t.album_id
|
||||||
WHERE gl.user_id = $1;
|
WHERE gl.user_id = $1;
|
||||||
|
|
||||||
-- name: ListLikedArtistIDsForUser :many
|
-- name: ListLikedArtistIDsForUser :many
|
||||||
@@ -66,3 +70,33 @@ FROM taste_profile_tags
|
|||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY weight DESC
|
ORDER BY weight DESC
|
||||||
LIMIT $2;
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- name: DeleteTasteProfileErasForUser :exec
|
||||||
|
DELETE FROM taste_profile_eras WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: InsertTasteProfileEra :exec
|
||||||
|
INSERT INTO taste_profile_eras (user_id, era, weight)
|
||||||
|
VALUES ($1, $2, $3);
|
||||||
|
|
||||||
|
-- name: ListTasteProfileErasForUser :many
|
||||||
|
-- Top-weighted taste eras (#1530); consumed by the scorer's era term.
|
||||||
|
SELECT era, weight
|
||||||
|
FROM taste_profile_eras
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY weight DESC
|
||||||
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- name: DeleteTasteProfileMoodsForUser :exec
|
||||||
|
DELETE FROM taste_profile_moods WHERE user_id = $1;
|
||||||
|
|
||||||
|
-- name: InsertTasteProfileMood :exec
|
||||||
|
INSERT INTO taste_profile_moods (user_id, mood, weight)
|
||||||
|
VALUES ($1, $2, $3);
|
||||||
|
|
||||||
|
-- name: ListTasteProfileMoodsForUser :many
|
||||||
|
-- Top-weighted taste moods (#1534); consumed by the scorer's mood term.
|
||||||
|
SELECT mood, weight
|
||||||
|
FROM taste_profile_moods
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY weight DESC
|
||||||
|
LIMIT $2;
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
-- Tracks eligible for tag enrichment: never processed (tag_source NULL)
|
-- Tracks eligible for tag enrichment: never processed (tag_source NULL)
|
||||||
-- or previously settled 'none' under an older provider version. Returns
|
-- or previously settled 'none' under an older provider version. Returns
|
||||||
-- the fields the provider chain needs — recording MBID (nullable) for
|
-- the fields the provider chain needs — recording MBID (nullable) for
|
||||||
-- keyed lookups, plus title + artist name for name-based fallback.
|
-- keyed lookups, title + artist name for name-based fallback, and the
|
||||||
|
-- artist MBID (nullable) for MusicBrainz's artist-tag fallback (#1519).
|
||||||
-- $1 = current tag_sources_version, $2 = limit.
|
-- $1 = current tag_sources_version, $2 = limit.
|
||||||
SELECT t.id, t.mbid, t.title, a.name AS artist_name
|
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN artists a ON a.id = t.artist_id
|
JOIN artists a ON a.id = t.artist_id
|
||||||
WHERE t.tag_source IS NULL
|
WHERE t.tag_source IS NULL
|
||||||
@@ -55,3 +56,11 @@ SELECT tt.track_id, tt.tag, tt.weight
|
|||||||
FROM track_tags tt
|
FROM track_tags tt
|
||||||
JOIN general_likes gl ON gl.track_id = tt.track_id
|
JOIN general_likes gl ON gl.track_id = tt.track_id
|
||||||
WHERE gl.user_id = $1;
|
WHERE gl.user_id = $1;
|
||||||
|
|
||||||
|
-- name: ListTrackTagsForTracks :many
|
||||||
|
-- Enriched tags for a set of candidate tracks (#1534), so the scorer can
|
||||||
|
-- derive each candidate's mood buckets at scoring time. One row per (track,
|
||||||
|
-- tag); the caller filters to mood words via the internal/mood vocabulary.
|
||||||
|
SELECT tt.track_id, tt.tag
|
||||||
|
FROM track_tags tt
|
||||||
|
WHERE tt.track_id = ANY($1::uuid[]);
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ var dataTables = []string{
|
|||||||
"sessions",
|
"sessions",
|
||||||
"lidarr_quarantine_actions",
|
"lidarr_quarantine_actions",
|
||||||
"lidarr_quarantine",
|
"lidarr_quarantine",
|
||||||
|
// #2374. Keyed by (user_id, candidate_mbid) with no FK to artists —
|
||||||
|
// candidates are out-of-library — so the CASCADE from artists/users
|
||||||
|
// does NOT reach it for a leftover row whose user survived. Truncate
|
||||||
|
// explicitly or a stale snooze silently hides a candidate from the
|
||||||
|
// next test's suggestion assertions.
|
||||||
|
"suggestion_snoozes",
|
||||||
|
// #2376. Same reasoning: keyed by candidate MBID with no FK anywhere,
|
||||||
|
// so nothing cascades to them. A leftover tag row would make a
|
||||||
|
// candidate look enriched to the next test, and a leftover state row
|
||||||
|
// would make it look already-settled and thus ineligible.
|
||||||
|
"candidate_artist_tags",
|
||||||
|
"candidate_artist_tag_state",
|
||||||
"playlist_tracks",
|
"playlist_tracks",
|
||||||
"playlists",
|
"playlists",
|
||||||
"library_changes", // M7 #357 — must reset to keep cursor isolated per test
|
"library_changes", // M7 #357 — must reset to keep cursor isolated per test
|
||||||
@@ -70,6 +82,10 @@ var dataTables = []string{
|
|||||||
// (#1250), so truncating gives each test pristine tuning values.
|
// (#1250), so truncating gives each test pristine tuning values.
|
||||||
"recommendation_weight_profiles",
|
"recommendation_weight_profiles",
|
||||||
"taste_tuning",
|
"taste_tuning",
|
||||||
|
// #2377. Same reasoning as taste_tuning above: recsettings.New re-seeds
|
||||||
|
// shipped defaults on every construction, so truncating gives each test
|
||||||
|
// pristine Discover knobs rather than whatever a previous test tuned.
|
||||||
|
"discover_tuning",
|
||||||
"recommendation_tuning_audit",
|
"recommendation_tuning_audit",
|
||||||
"tracks",
|
"tracks",
|
||||||
"albums",
|
"albums",
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
// - GcResetStuckSystemPlaylistRuns (#574)
|
// - GcResetStuckSystemPlaylistRuns (#574)
|
||||||
// - GcDeleteExpiredPasswordResets (#575)
|
// - GcDeleteExpiredPasswordResets (#575)
|
||||||
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
|
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
|
||||||
|
// - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go)
|
||||||
|
// - GcDeleteOrphanedCandidateArtistTags(+State) (#2376 — the similarity
|
||||||
|
// feed churns, so cached candidate tags outlive their candidates)
|
||||||
package gc
|
package gc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -84,6 +87,14 @@ func (w *Worker) tickOnce(ctx context.Context) {
|
|||||||
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
||||||
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
||||||
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
|
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
|
||||||
|
w.runSweep(ctx, "delete_expired_suggestion_snoozes", q.GcDeleteExpiredSuggestionSnoozes)
|
||||||
|
// Tags before state: if the process dies between the two, a candidate left
|
||||||
|
// with a state row and no tags simply reads as "settled, nothing found",
|
||||||
|
// which is already a valid state. The reverse order could leave tags with
|
||||||
|
// no state row, which the drainer would treat as never-processed and
|
||||||
|
// re-fetch on top of rows that are already there.
|
||||||
|
w.runSweep(ctx, "orphaned_candidate_artist_tags", q.GcDeleteOrphanedCandidateArtistTags)
|
||||||
|
w.runSweep(ctx, "orphaned_candidate_artist_tag_state", q.GcDeleteOrphanedCandidateArtistTagState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSweep is a small adapter so each sweep call site is a one-liner
|
// runSweep is a small adapter so each sweep call site is a one-liner
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Package mood maps folksonomy tags (from the track_tags cache, #1490) to a
|
||||||
|
// small set of canonical mood buckets (#1534, milestone #160 Opt 2b). Both the
|
||||||
|
// taste-profile builder and the recommendation scorer classify a track's tags
|
||||||
|
// through Of() so a track's mood is derived the same way on both sides.
|
||||||
|
//
|
||||||
|
// The vocabulary is intentionally narrow: only words that clearly denote mood
|
||||||
|
// (not genre). A track with no mood-word tags contributes nothing — the mood
|
||||||
|
// facet is a supplement whose coverage grows with folksonomy enrichment
|
||||||
|
// (thin on MusicBrainz-only, richer once Last.fm is keyed).
|
||||||
|
package mood
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// vocab maps a lowercased folksonomy tag to its canonical mood bucket. Several
|
||||||
|
// synonyms collapse to one bucket so "sad" / "melancholy" / "melancholic" all
|
||||||
|
// score the same dimension.
|
||||||
|
var vocab = map[string]string{
|
||||||
|
// melancholic
|
||||||
|
"melancholic": "melancholic", "melancholy": "melancholic", "sad": "melancholic",
|
||||||
|
"sombre": "melancholic", "somber": "melancholic", "wistful": "melancholic",
|
||||||
|
"bittersweet": "melancholic", "mournful": "melancholic",
|
||||||
|
// energetic
|
||||||
|
"energetic": "energetic", "energy": "energetic", "upbeat": "energetic",
|
||||||
|
"uplifting": "energetic", "party": "energetic", "anthemic": "energetic",
|
||||||
|
// chill
|
||||||
|
"chill": "chill", "chillout": "chill", "chilled": "chill", "mellow": "chill",
|
||||||
|
"relaxing": "chill", "relaxed": "chill", "calm": "chill", "laid-back": "chill",
|
||||||
|
"laidback": "chill", "soothing": "chill",
|
||||||
|
// aggressive
|
||||||
|
"aggressive": "aggressive", "angry": "aggressive", "intense": "aggressive",
|
||||||
|
"heavy": "aggressive", "brutal": "aggressive",
|
||||||
|
// dark
|
||||||
|
"dark": "dark", "moody": "dark", "brooding": "dark", "haunting": "dark", "eerie": "dark",
|
||||||
|
// dreamy
|
||||||
|
"dreamy": "dreamy", "ethereal": "dreamy", "atmospheric": "dreamy",
|
||||||
|
"ambient": "dreamy", "hypnotic": "dreamy",
|
||||||
|
// happy
|
||||||
|
"happy": "happy", "cheerful": "happy", "feel good": "happy", "feel-good": "happy",
|
||||||
|
"feelgood": "happy", "joyful": "happy", "fun": "happy",
|
||||||
|
// romantic
|
||||||
|
"romantic": "romantic", "sensual": "romantic", "sexy": "romantic", "sentimental": "romantic",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Of returns the distinct canonical mood buckets present in tags, lowercasing +
|
||||||
|
// trimming each tag before lookup. Returns nil when no tag maps to a mood, so
|
||||||
|
// callers get no mood signal for a track with only genre/descriptive tags.
|
||||||
|
func Of(tags []string) []string {
|
||||||
|
var out []string
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for _, t := range tags {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(t))
|
||||||
|
if m, ok := vocab[key]; ok && !seen[m] {
|
||||||
|
seen[m] = true
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package mood
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOf(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in []string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"synonyms collapse", []string{"sad", "melancholy", "melancholic"}, []string{"melancholic"}},
|
||||||
|
{"case + whitespace", []string{" CHILL ", "Mellow"}, []string{"chill"}},
|
||||||
|
{"multiple buckets", []string{"energetic", "dark"}, []string{"dark", "energetic"}},
|
||||||
|
{"genre-only → none", []string{"rock", "shoegaze", "post-punk"}, nil},
|
||||||
|
{"empty", nil, nil},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := Of(c.in)
|
||||||
|
sort.Strings(got)
|
||||||
|
want := append([]string(nil), c.want...)
|
||||||
|
sort.Strings(want)
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("%s: Of(%v) = %v, want %v", c.name, c.in, got, want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("%s: Of(%v) = %v, want %v", c.name, c.in, got, want)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOf_Dedupes(t *testing.T) {
|
||||||
|
// Three tags mapping to the same bucket yield one entry.
|
||||||
|
if got := Of([]string{"chill", "mellow", "relaxed"}); len(got) != 1 || got[0] != "chill" {
|
||||||
|
t.Errorf("Of dedupe = %v, want [chill]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,16 @@ func (w *Writer) RecordPlayStarted(
|
|||||||
clientID string,
|
clientID string,
|
||||||
at time.Time,
|
at time.Time,
|
||||||
) (StartedResult, error) {
|
) (StartedResult, error) {
|
||||||
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", at)
|
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", "", at)
|
||||||
|
}
|
||||||
|
|
||||||
|
// strPtrOrNil maps an empty string to a NULL text column (nullable pointer),
|
||||||
|
// mirroring the clientID/source nil-on-empty pattern used above.
|
||||||
|
func strPtrOrNil(s string) *string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
// systemPlaylistSources are the play_events.source values that count
|
// systemPlaylistSources are the play_events.source values that count
|
||||||
@@ -136,7 +145,7 @@ func lookupSystemPickKind(
|
|||||||
func (w *Writer) RecordPlayStartedWithSource(
|
func (w *Writer) RecordPlayStartedWithSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID, trackID pgtype.UUID,
|
userID, trackID pgtype.UUID,
|
||||||
clientID, source string,
|
clientID, source, deviceClass string,
|
||||||
at time.Time,
|
at time.Time,
|
||||||
) (StartedResult, error) {
|
) (StartedResult, error) {
|
||||||
var out StartedResult
|
var out StartedResult
|
||||||
@@ -165,13 +174,14 @@ func (w *Writer) RecordPlayStartedWithSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
TrackID: trackID,
|
TrackID: trackID,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||||
ClientID: clientIDPtr,
|
ClientID: clientIDPtr,
|
||||||
Source: sourcePtr,
|
Source: sourcePtr,
|
||||||
PickKind: pickKind,
|
PickKind: pickKind,
|
||||||
|
DeviceClass: strPtrOrNil(deviceClass),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -389,7 +399,7 @@ func (w *Writer) RecordSyntheticCompletedPlay(
|
|||||||
func (w *Writer) RecordOfflinePlay(
|
func (w *Writer) RecordOfflinePlay(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID, trackID pgtype.UUID,
|
userID, trackID pgtype.UUID,
|
||||||
clientID, source string,
|
clientID, source, deviceClass string,
|
||||||
at time.Time,
|
at time.Time,
|
||||||
durationPlayedMs int32,
|
durationPlayedMs int32,
|
||||||
) error {
|
) error {
|
||||||
@@ -441,13 +451,14 @@ func (w *Writer) RecordOfflinePlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
TrackID: trackID,
|
TrackID: trackID,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||||
ClientID: clientIDPtr,
|
ClientID: clientIDPtr,
|
||||||
Source: sourcePtr,
|
Source: sourcePtr,
|
||||||
PickKind: pickKind,
|
PickKind: pickKind,
|
||||||
|
DeviceClass: strPtrOrNil(deviceClass),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -226,10 +226,10 @@ func TestRecordOfflinePlay_DedupsByUserTrackStartedAt(t *testing.T) {
|
|||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
at := time.Now().UTC()
|
at := time.Now().UTC()
|
||||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
|
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", "", at, 120_000); err != nil {
|
||||||
t.Fatalf("first offline: %v", err)
|
t.Fatalf("first offline: %v", err)
|
||||||
}
|
}
|
||||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
|
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", "", at, 120_000); err != nil {
|
||||||
t.Fatalf("replay offline: %v", err)
|
t.Fatalf("replay offline: %v", err)
|
||||||
}
|
}
|
||||||
var count int
|
var count int
|
||||||
@@ -444,7 +444,7 @@ func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
|
|||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if _, err := f.w.RecordPlayStartedWithSource(
|
if _, err := f.w.RecordPlayStartedWithSource(
|
||||||
context.Background(), f.user, f.track, "c", "for_you", now,
|
context.Background(), f.user, f.track, "c", "for_you", "", now,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
}
|
}
|
||||||
@@ -460,7 +460,7 @@ func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
|
|||||||
|
|
||||||
// Re-playing the same track stays a set (no duplicate append).
|
// Re-playing the same track stays a set (no duplicate append).
|
||||||
if _, err := f.w.RecordPlayStartedWithSource(
|
if _, err := f.w.RecordPlayStartedWithSource(
|
||||||
context.Background(), f.user, f.track, "c", "for_you", now.Add(time.Minute),
|
context.Background(), f.user, f.track, "c", "for_you", "", now.Add(time.Minute),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource 2: %v", err)
|
t.Fatalf("RecordPlayStartedWithSource 2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -511,7 +511,7 @@ func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
|
|||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
seedSystemSnapshot(t, f, "for_you", "fresh")
|
seedSystemSnapshot(t, f, "for_you", "fresh")
|
||||||
res, err := f.w.RecordPlayStartedWithSource(
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
|
context.Background(), f.user, f.track, "c", "for_you", "", time.Now().UTC(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
@@ -530,7 +530,7 @@ func TestRecordPlayStartedWithSource_NoSnapshotMatch_PickKindNull(t *testing.T)
|
|||||||
// out, or no snapshot exists) stays unattributed rather than guessing.
|
// out, or no snapshot exists) stays unattributed rather than guessing.
|
||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
res, err := f.w.RecordPlayStartedWithSource(
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
|
context.Background(), f.user, f.track, "c", "for_you", "", time.Now().UTC(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
@@ -552,7 +552,7 @@ func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
|
|||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
seedSystemSnapshot(t, f, "for_you", "taste")
|
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||||
res, err := f.w.RecordPlayStartedWithSource(
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
|
context.Background(), f.user, f.track, "c", "discover", "", time.Now().UTC(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
@@ -572,7 +572,7 @@ func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
|
|||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
seedSystemSnapshot(t, f, "discover", "dormant")
|
seedSystemSnapshot(t, f, "discover", "dormant")
|
||||||
res, err := f.w.RecordPlayStartedWithSource(
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
|
context.Background(), f.user, f.track, "c", "discover", "", time.Now().UTC(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
@@ -593,7 +593,7 @@ func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
|
|||||||
seedSystemSnapshot(t, f, "for_you", "taste")
|
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||||
at := time.Now().UTC().Add(-time.Hour)
|
at := time.Now().UTC().Add(-time.Hour)
|
||||||
if err := f.w.RecordOfflinePlay(
|
if err := f.w.RecordOfflinePlay(
|
||||||
context.Background(), f.user, f.track, "c", "for_you", at, 180_000,
|
context.Background(), f.user, f.track, "c", "for_you", "", at, 180_000,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("RecordOfflinePlay: %v", err)
|
t.Fatalf("RecordOfflinePlay: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ var (
|
|||||||
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
||||||
// while passive avoidance (negative) gently demotes.
|
// while passive avoidance (negative) gently demotes.
|
||||||
TasteWeight: 1.5,
|
TasteWeight: 1.5,
|
||||||
|
// Time-of-day/weekday context affinity (#1531), in [-1,+1]. Starts
|
||||||
|
// uniform with radio pending trend data.
|
||||||
|
ContextTimeWeight: 1.0,
|
||||||
}
|
}
|
||||||
systemTasteConfig = taste.DefaultConfig()
|
systemTasteConfig = taste.DefaultConfig()
|
||||||
)
|
)
|
||||||
@@ -543,6 +546,9 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
|||||||
// deep slice of the user's top taste-profile artists into the pool
|
// deep slice of the user's top taste-profile artists into the pool
|
||||||
// (#796 phase 2b). Empty for cold-start users (no profile yet).
|
// (#796 phase 2b). Empty for cold-start users (no profile yet).
|
||||||
TasteOverlap: 80,
|
TasteOverlap: 80,
|
||||||
|
// Household co-play (#1533): a deeper collaborative slice for the
|
||||||
|
// taste surfaces. Empty on single-user servers.
|
||||||
|
UserCoplay: 40,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Slice 5 (#2376): the candidate-artist tag cache for out-of-library Discover
|
||||||
|
// candidates. These cover the SQL rather than the provider chain — the
|
||||||
|
// eligibility query is the piece with real risk in it (a GROUP BY over a
|
||||||
|
// many-rows-per-candidate table, a LEFT JOIN to bookkeeping, and two exclusion
|
||||||
|
// branches), and it's consumed by this package's slice-6 ranking.
|
||||||
|
//
|
||||||
|
// Fixtures live here because the harness and seedUnmatched do.
|
||||||
|
|
||||||
|
const (
|
||||||
|
tagVersionCurrent = 2
|
||||||
|
tagVersionOld = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// listEligible is the query under test, at the current provider version.
|
||||||
|
func listEligible(t *testing.T, pool *pgxpool.Pool, limit int32) []dbq.ListCandidateArtistsMissingTagsRow {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := dbq.New(pool).ListCandidateArtistsMissingTags(context.Background(),
|
||||||
|
dbq.ListCandidateArtistsMissingTagsParams{
|
||||||
|
TagSourcesVersion: tagVersionCurrent,
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCandidateArtistsMissingTags: %v", err)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func setState(t *testing.T, pool *pgxpool.Pool, mbid, source string, version int32) {
|
||||||
|
t.Helper()
|
||||||
|
if err := dbq.New(pool).SetCandidateArtistTagState(context.Background(),
|
||||||
|
dbq.SetCandidateArtistTagStateParams{
|
||||||
|
CandidateMbid: mbid, TagSource: source, TagSourcesVersion: version,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetCandidateArtistTagState: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mbidsOf(rows []dbq.ListCandidateArtistsMissingTagsRow) []string {
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, r.CandidateMbid)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateTags_NeverProcessedIsEligible(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
likeArtist(t, pool, user.ID, seed.ID)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
|
||||||
|
|
||||||
|
rows := listEligible(t, pool, 10)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1: %v", len(rows), mbidsOf(rows))
|
||||||
|
}
|
||||||
|
if rows[0].CandidateName != "Candidate One" {
|
||||||
|
t.Errorf("name = %q, want Candidate One", rows[0].CandidateName)
|
||||||
|
}
|
||||||
|
if rows[0].TotalScore != 0.9 {
|
||||||
|
t.Errorf("score = %v, want 0.9", rows[0].TotalScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An in-library candidate's tags belong in track_tags, and the suggestion query
|
||||||
|
// filters it out anyway — enriching it would be wasted API budget.
|
||||||
|
func TestCandidateTags_InLibraryCandidateIsExcluded(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
seedArtist(t, pool, "Already Here", "cand-in-lib")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "cand-in-lib", "Already Here", 0.9)
|
||||||
|
|
||||||
|
if rows := listEligible(t, pool, 10); len(rows) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0: %v", len(rows), mbidsOf(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateTags_SettledWithTagsIsExcluded(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
|
||||||
|
setState(t, pool, "cand-1", "musicbrainz", tagVersionCurrent)
|
||||||
|
|
||||||
|
if rows := listEligible(t, pool, 10); len(rows) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0 (already enriched): %v", len(rows), mbidsOf(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A candidate that settled 'none' becomes eligible again when the provider set
|
||||||
|
// widens (version bump) — that's the whole point of the version column. It must
|
||||||
|
// NOT be eligible at the current version, or the worker re-fetches it forever.
|
||||||
|
func TestCandidateTags_SettledNoneReopensOnlyOnVersionBump(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
|
||||||
|
|
||||||
|
setState(t, pool, "cand-1", "none", tagVersionCurrent)
|
||||||
|
if rows := listEligible(t, pool, 10); len(rows) != 0 {
|
||||||
|
t.Errorf("current version: len = %d, want 0 (settled)", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(t, pool, "cand-1", "none", tagVersionOld)
|
||||||
|
if rows := listEligible(t, pool, 10); len(rows) != 1 {
|
||||||
|
t.Errorf("older version: len = %d, want 1 (eligible again)", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// artist_similarity_unmatched holds one row per (seed, candidate, source).
|
||||||
|
// Without the GROUP BY, a candidate that five seeds point at would be fetched
|
||||||
|
// five times — five times the MusicBrainz budget for identical data.
|
||||||
|
func TestCandidateTags_ManySeedsCollapseToOneRowAndSumScores(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
seedA := seedArtist(t, pool, "Seed A", "")
|
||||||
|
seedB := seedArtist(t, pool, "Seed B", "")
|
||||||
|
seedUnmatched(t, pool, seedA.ID, "cand-1", "Candidate One", 0.4)
|
||||||
|
seedUnmatched(t, pool, seedB.ID, "cand-1", "Candidate One", 0.3)
|
||||||
|
|
||||||
|
rows := listEligible(t, pool, 10)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (grouped): %v", len(rows), mbidsOf(rows))
|
||||||
|
}
|
||||||
|
if got := rows[0].TotalScore; got < 0.69 || got > 0.71 {
|
||||||
|
t.Errorf("total_score = %v, want ~0.7 (summed across seeds)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool is far larger than one pass can drain at ~1 req/s, so the ordering
|
||||||
|
// IS the feature: the strongest candidates must be enriched first, or the ones
|
||||||
|
// that actually reach a user's deck starve behind the long tail.
|
||||||
|
func TestCandidateTags_StrongestCandidatesComeFirst(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "weak", "Weak", 0.1)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "strong", "Strong", 0.95)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "middle", "Middle", 0.5)
|
||||||
|
|
||||||
|
rows := listEligible(t, pool, 10)
|
||||||
|
want := []string{"strong", "middle", "weak"}
|
||||||
|
got := mbidsOf(rows)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len = %d, want 3: %v", len(got), got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("order = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the limit takes the strongest, not an arbitrary slice.
|
||||||
|
if top := mbidsOf(listEligible(t, pool, 1)); len(top) != 1 || top[0] != "strong" {
|
||||||
|
t.Errorf("limit 1 returned %v, want [strong]", top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateTags_InsertKeepsTheStrongerWeight(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
ins := func(w float64) {
|
||||||
|
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
|
||||||
|
CandidateMbid: "cand-1", Tag: "shoegaze", Weight: w,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertCandidateArtistTag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ins(0.8)
|
||||||
|
ins(0.3) // weaker second write must not clobber
|
||||||
|
|
||||||
|
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"cand-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Weight != 0.8 {
|
||||||
|
t.Errorf("weight = %v, want 0.8 (GREATEST)", rows[0].Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateTags_ListForMbidsIgnoresUnaskedCandidates(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, mbid := range []string{"want-1", "want-2", "other"} {
|
||||||
|
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
|
||||||
|
CandidateMbid: mbid, Tag: "rock", Weight: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", mbid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"want-1", "want-2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Errorf("len = %d, want 2", len(rows))
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.CandidateMbid == "other" {
|
||||||
|
t.Error("returned a candidate that wasn't asked for")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The similarity feed is refetched and churns, so without the sweep the cache
|
||||||
|
// only grows. Both halves must survive/die together for the right candidates.
|
||||||
|
func TestCandidateTags_GcDropsOrphansAndKeepsLiveOnes(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "live", "Live", 0.9)
|
||||||
|
// "gone" is cached but no longer in the feed; "adopted" got added to the
|
||||||
|
// library since, so its tags belong in track_tags now.
|
||||||
|
seedArtist(t, pool, "Adopted", "adopted")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "adopted", "Adopted", 0.8)
|
||||||
|
|
||||||
|
for _, mbid := range []string{"live", "gone", "adopted"} {
|
||||||
|
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
|
||||||
|
CandidateMbid: mbid, Tag: "rock", Weight: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", mbid, err)
|
||||||
|
}
|
||||||
|
setState(t, pool, mbid, "musicbrainz", tagVersionCurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := q.GcDeleteOrphanedCandidateArtistTags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GcDeleteOrphanedCandidateArtistTags: %v", err)
|
||||||
|
}
|
||||||
|
if deleted != 2 {
|
||||||
|
t.Errorf("deleted %d tag rows, want 2 (gone + adopted)", deleted)
|
||||||
|
}
|
||||||
|
deletedState, err := q.GcDeleteOrphanedCandidateArtistTagState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GcDeleteOrphanedCandidateArtistTagState: %v", err)
|
||||||
|
}
|
||||||
|
if deletedState != 2 {
|
||||||
|
t.Errorf("deleted %d state rows, want 2", deletedState)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"live", "gone", "adopted"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].CandidateMbid != "live" {
|
||||||
|
t.Errorf("survivors = %v, want [live] only", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage is the operator's window onto the honest ceiling: processed vs
|
||||||
|
// actually-tagged. A big gap means thin upstream data, not a broken worker.
|
||||||
|
func TestCandidateTags_CoverageCountsProcessedAndTagged(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
setState(t, pool, "has-tags", "musicbrainz", tagVersionCurrent)
|
||||||
|
setState(t, pool, "mixed-tags", "mixed", tagVersionCurrent)
|
||||||
|
setState(t, pool, "no-tags", "none", tagVersionCurrent)
|
||||||
|
|
||||||
|
got, err := dbq.New(pool).CountCandidateArtistTagCoverage(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountCandidateArtistTagCoverage: %v", err)
|
||||||
|
}
|
||||||
|
if got.Processed != 3 {
|
||||||
|
t.Errorf("processed = %d, want 3", got.Processed)
|
||||||
|
}
|
||||||
|
if got.WithTags != 2 {
|
||||||
|
t.Errorf("with_tags = %d, want 2 ('none' excluded)", got.WithTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Slice 6 (#2377): the taste-tag blend, end to end ---
|
||||||
|
//
|
||||||
|
// The pure tests in tagoverlap_test.go cover the scoring maths. These cover the
|
||||||
|
// wiring the pure tests cannot reach: that loadTagInputs actually reads both
|
||||||
|
// sides from the DB and that the blend reaches the returned deck.
|
||||||
|
|
||||||
|
func seedTasteTag(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string, weight float64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, tag) DO UPDATE SET weight = EXCLUDED.weight`,
|
||||||
|
userID, tag, weight,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seedTasteTag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedCandidateTag(t *testing.T, pool *pgxpool.Pool, mbid, tag string, weight float64) {
|
||||||
|
t.Helper()
|
||||||
|
if err := dbq.New(pool).InsertCandidateArtistTag(context.Background(),
|
||||||
|
dbq.InsertCandidateArtistTagParams{CandidateMbid: mbid, Tag: tag, Weight: weight},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seedCandidateTag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// twoCandidatePool wires a liked seed with two unmatched neighbours: "loud"
|
||||||
|
// scores higher on similarity, "match" lower. Skewed on purpose — with equal
|
||||||
|
// similarity the reorder assertion below could not fail.
|
||||||
|
func twoCandidatePool(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
likeArtist(t, pool, userID, seed.ID)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "loud", "Loud Neighbour", 0.9)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "match", "Taste Match", 0.6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuggestArtists_TasteTagMatchOvertakesStrongerSimilarity(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
twoCandidatePool(t, pool, user.ID)
|
||||||
|
|
||||||
|
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
|
||||||
|
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
|
||||||
|
seedCandidateTag(t, pool, "loud", "death metal", 1.0)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "match" {
|
||||||
|
t.Errorf("first = %q, want match (0.6×2 beats 0.9×1)", out[0].MBID)
|
||||||
|
}
|
||||||
|
// And it explains itself.
|
||||||
|
if len(out[0].MatchedTags) != 1 || out[0].MatchedTags[0] != "shoegaze" {
|
||||||
|
t.Errorf("matched tags = %v, want [shoegaze]", out[0].MatchedTags)
|
||||||
|
}
|
||||||
|
if out[1].MatchedTags != nil {
|
||||||
|
t.Errorf("non-matching candidate got matched tags: %v", out[1].MatchedTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same fixture with the knob at 0 must return pure similarity order — the
|
||||||
|
// operator's off switch, verified against a real DB rather than assumed.
|
||||||
|
func TestSuggestArtists_ZeroTagWeightKeepsSimilarityOrder(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
twoCandidatePool(t, pool, user.ID)
|
||||||
|
|
||||||
|
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
|
||||||
|
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if out[0].MBID != "loud" {
|
||||||
|
t.Errorf("first = %q, want loud (tag term disabled)", out[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user with no taste profile must still get a full deck in similarity order:
|
||||||
|
// the cold-start path, which is every user's first days (rule #131).
|
||||||
|
func TestSuggestArtists_NoTasteTagsStillReturnsSimilarityOrder(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
twoCandidatePool(t, pool, user.ID)
|
||||||
|
// Candidate tags exist, but the user has no taste tags to match them.
|
||||||
|
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2 (nothing dropped)", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "loud" {
|
||||||
|
t.Errorf("first = %q, want loud", out[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An untagged candidate must never be dropped or sunk just because another
|
||||||
|
// candidate has tags — permanently-partial coverage (#2376) must not become a
|
||||||
|
// permanent ranking penalty.
|
||||||
|
func TestSuggestArtists_UntaggedCandidateSurvivesAlongsideTagged(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
twoCandidatePool(t, pool, user.ID)
|
||||||
|
|
||||||
|
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
|
||||||
|
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
|
||||||
|
// "loud" is deliberately left with NO cached tags at all.
|
||||||
|
|
||||||
|
// Compared against the same request with the term disabled, rather than
|
||||||
|
// against a literal. The pool score is signal-weighted by the seed query
|
||||||
|
// (ln(1+signal) x similarity), so hardcoding a number here would assert
|
||||||
|
// against the seeding maths — a different layer — and break whenever that
|
||||||
|
// changes. The property under test is only that the blend leaves an
|
||||||
|
// untagged candidate alone.
|
||||||
|
blended, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists(blended): %v", err)
|
||||||
|
}
|
||||||
|
baseline, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists(baseline): %v", err)
|
||||||
|
}
|
||||||
|
if len(blended) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2 — the untagged candidate must still appear", len(blended))
|
||||||
|
}
|
||||||
|
|
||||||
|
scoreOf := func(out []ArtistSuggestion, mbid string) (float64, bool) {
|
||||||
|
for _, s := range out {
|
||||||
|
if s.MBID == mbid {
|
||||||
|
return s.Score, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
want, ok := scoreOf(baseline, "loud")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("untagged candidate missing from the baseline deck")
|
||||||
|
}
|
||||||
|
got, ok := scoreOf(blended, "loud")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("untagged candidate vanished once the tag term was enabled")
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("untagged score = %v, want %v (identical to the term-disabled run)", got, want)
|
||||||
|
}
|
||||||
|
// Sanity: the tagged candidate DID move, so the comparison above is
|
||||||
|
// meaningful rather than both runs being trivially identical.
|
||||||
|
if tagged, _ := scoreOf(blended, "match"); tagged == mustScore(t, baseline, "match") {
|
||||||
|
t.Error("the tagged candidate's score did not change — the term did nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustScore(t *testing.T, out []ArtistSuggestion, mbid string) float64 {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range out {
|
||||||
|
if s.MBID == mbid {
|
||||||
|
return s.Score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no candidate %q in %v", mbid, out)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
||||||
@@ -41,6 +42,11 @@ func LoadCandidates(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]Candidate, 0, len(rows))
|
out := make([]Candidate, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
var lpt *time.Time
|
var lpt *time.Time
|
||||||
@@ -57,7 +63,11 @@ func LoadCandidates(
|
|||||||
PlayCount: int(r.PlayCount),
|
PlayCount: int(r.PlayCount),
|
||||||
SkipCount: int(r.SkipCount),
|
SkipCount: int(r.SkipCount),
|
||||||
ContextualMatchScore: ctxScore,
|
ContextualMatchScore: ctxScore,
|
||||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
// Fallback path: mood is scored only in the primary
|
||||||
|
// (similarity) loader — loading per-candidate tags over this
|
||||||
|
// near-whole-library pool isn't worth it (nil moods → 0).
|
||||||
|
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, nil),
|
||||||
|
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -76,6 +86,10 @@ type CandidateSourceLimits struct {
|
|||||||
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
|
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
|
||||||
// users have an empty profile, so it contributes nothing anyway).
|
// users have an empty profile, so it contributes nothing anyway).
|
||||||
TasteOverlap int
|
TasteOverlap int
|
||||||
|
// UserCoplay (#1533): tracks by artists co-played across the instance
|
||||||
|
// with the seed's artist (source='user_cooccurrence'). Empty on
|
||||||
|
// single-user servers, so it contributes nothing there.
|
||||||
|
UserCoplay int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
||||||
@@ -87,6 +101,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
|||||||
LikesOverlap: 20,
|
LikesOverlap: 20,
|
||||||
RandomFill: 30,
|
RandomFill: 30,
|
||||||
TasteOverlap: 20,
|
TasteOverlap: 20,
|
||||||
|
UserCoplay: 20,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +134,7 @@ func LoadCandidatesFromSimilarity(
|
|||||||
Limit_4: int32(limits.LikesOverlap),
|
Limit_4: int32(limits.LikesOverlap),
|
||||||
Limit_5: int32(limits.RandomFill),
|
Limit_5: int32(limits.RandomFill),
|
||||||
Limit_6: int32(limits.TasteOverlap),
|
Limit_6: int32(limits.TasteOverlap),
|
||||||
|
Limit_7: int32(limits.UserCoplay),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -134,6 +150,20 @@ func LoadCandidatesFromSimilarity(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
trackIDs := make([]pgtype.UUID, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
trackIDs = append(trackIDs, r.Track.ID)
|
||||||
|
}
|
||||||
|
moods, err := loadCandidateMoods(ctx, q, trackIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]Candidate, 0, len(rows))
|
out := make([]Candidate, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
var lpt *time.Time
|
var lpt *time.Time
|
||||||
@@ -158,13 +188,42 @@ func LoadCandidatesFromSimilarity(
|
|||||||
SkipCount: int(r.SkipCount),
|
SkipCount: int(r.SkipCount),
|
||||||
ContextualMatchScore: ctxScore,
|
ContextualMatchScore: ctxScore,
|
||||||
SimilarityScore: simScore,
|
SimilarityScore: simScore,
|
||||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
TasteMatchScore: profile.Match(
|
||||||
|
r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, moods[r.Track.ID]),
|
||||||
|
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// loadCandidateMoods fetches the enriched tags for the given candidate tracks
|
||||||
|
// and reduces each to its canonical mood buckets (internal/mood, #1534), so the
|
||||||
|
// scorer can apply the mood facet per candidate. Tracks with no mood-word tags
|
||||||
|
// are absent from the map (→ no mood signal). Empty input short-circuits.
|
||||||
|
func loadCandidateMoods(
|
||||||
|
ctx context.Context, q *dbq.Queries, trackIDs []pgtype.UUID,
|
||||||
|
) (map[pgtype.UUID][]string, error) {
|
||||||
|
if len(trackIDs) == 0 {
|
||||||
|
return map[pgtype.UUID][]string{}, nil
|
||||||
|
}
|
||||||
|
rows, err := q.ListTrackTagsForTracks(ctx, trackIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tagsByTrack := make(map[pgtype.UUID][]string)
|
||||||
|
for _, r := range rows {
|
||||||
|
tagsByTrack[r.TrackID] = append(tagsByTrack[r.TrackID], r.Tag)
|
||||||
|
}
|
||||||
|
out := make(map[pgtype.UUID][]string, len(tagsByTrack))
|
||||||
|
for id, tags := range tagsByTrack {
|
||||||
|
if m := mood.Of(tags); len(m) > 0 {
|
||||||
|
out[id] = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
||||||
// one query and groups them by track_id. Rows whose session_vector fails
|
// one query and groups them by track_id. Rows whose session_vector fails
|
||||||
// to unmarshal are skipped with no error (don't poison scoring over one
|
// to unmarshal are skipped with no error (don't poison scoring over one
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contextAffinityShrinkage is the pseudo-count that pulls a low-play artist's
|
||||||
|
// cell-share toward the user's baseline, so an artist with one or two plays
|
||||||
|
// can't swing its affinity to ±1 on noise. At k plays the estimate sits
|
||||||
|
// halfway between the raw cell-share and the baseline.
|
||||||
|
const contextAffinityShrinkage = 5.0
|
||||||
|
|
||||||
|
// ContextAffinity is the read-side map of per-artist time-of-day/weekday
|
||||||
|
// affinity for the CURRENT context (#1531): artist_id → score in [-1, +1].
|
||||||
|
// Absent artists (no play history) score 0, so cold-start candidates stay
|
||||||
|
// neutral. The zero value is a valid all-neutral affinity.
|
||||||
|
type ContextAffinity struct {
|
||||||
|
byArtist map[pgtype.UUID]float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Affinity returns the artist's current-context affinity, or 0 if unknown.
|
||||||
|
func (c ContextAffinity) Affinity(artistID pgtype.UUID) float64 {
|
||||||
|
return c.byArtist[artistID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadContextAffinity computes each artist's affinity for the user's CURRENT
|
||||||
|
// context cell — daypart × weekday, narrowed by deviceClass when non-empty
|
||||||
|
// (#1551; radio passes the current device, the daily mixes pass "" for a
|
||||||
|
// device-agnostic cell). For every artist with completed plays in the window it
|
||||||
|
// compares the share of that artist's plays that fall in the current cell
|
||||||
|
// against the user's overall baseline share, shrinking sparse artists toward
|
||||||
|
// the baseline. Returns an empty (all-neutral) affinity when the user has no
|
||||||
|
// plays.
|
||||||
|
func LoadContextAffinity(
|
||||||
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, deviceClass string,
|
||||||
|
) (ContextAffinity, error) {
|
||||||
|
rows, err := q.ListArtistContextPlayCountsForUser(ctx, dbq.ListArtistContextPlayCountsForUserParams{
|
||||||
|
ID: userID,
|
||||||
|
Column2: deviceClass,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ContextAffinity{}, err
|
||||||
|
}
|
||||||
|
var totalPlays, cellPlays int64
|
||||||
|
for _, r := range rows {
|
||||||
|
totalPlays += r.TotalPlays
|
||||||
|
cellPlays += r.CellPlays
|
||||||
|
}
|
||||||
|
out := ContextAffinity{byArtist: make(map[pgtype.UUID]float64, len(rows))}
|
||||||
|
if totalPlays == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
baseline := float64(cellPlays) / float64(totalPlays)
|
||||||
|
for _, r := range rows {
|
||||||
|
out.byArtist[r.ArtistID] = contextAffinity(
|
||||||
|
float64(r.CellPlays), float64(r.TotalPlays), baseline, contextAffinityShrinkage)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextAffinity returns an artist's shrunk cell-share minus the user's
|
||||||
|
// baseline share, clamped to [-1, 1]. The shrinkage pseudo-count k pulls
|
||||||
|
// low-play artists toward the baseline (→ 0 affinity) so noise can't dominate;
|
||||||
|
// a heavily-played artist keeps close to its raw over/under-representation.
|
||||||
|
func contextAffinity(cellPlays, totalPlays, baseline, k float64) float64 {
|
||||||
|
if totalPlays == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
shrunk := (cellPlays + baseline*k) / (totalPlays + k)
|
||||||
|
return clampUnit(shrunk - baseline)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func insertPlayEventDevice(
|
||||||
|
t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, device string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var sessionID pgtype.UUID
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||||
|
VALUES ($1, $2, $2, 'ctx-dev-test') RETURNING id`,
|
||||||
|
userID, startedAt).Scan(&sessionID); err != nil {
|
||||||
|
t.Fatalf("insert play_session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped, device_class)
|
||||||
|
VALUES ($1, $2, $3, $4, false, $5)`,
|
||||||
|
userID, trackID, sessionID, startedAt, device); err != nil {
|
||||||
|
t.Fatalf("insert play_event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadContextAffinity_DeviceNarrowing seeds one artist played only on
|
||||||
|
// mobile and another only on web (all in the current time cell), and verifies
|
||||||
|
// the current-device parameter narrows the affinity cell (#1551): on 'mobile'
|
||||||
|
// the mobile artist out-scores the web artist and vice-versa, while the
|
||||||
|
// device-agnostic (”) pass treats them equally.
|
||||||
|
func TestLoadContextAffinity_DeviceNarrowing(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
u := seedUser(t, pool, "ctx-dev")
|
||||||
|
aMobile := seedArtist(t, pool, "MobileArtist", "")
|
||||||
|
aWeb := seedArtist(t, pool, "WebArtist", "")
|
||||||
|
alM := seedAlbumForArtist(t, pool, aMobile.ID, "AlbM")
|
||||||
|
tM := seedTrackOnAlbum(t, pool, alM.ID, aMobile.ID, "TrkM")
|
||||||
|
alW := seedAlbumForArtist(t, pool, aWeb.ID, "AlbW")
|
||||||
|
tW := seedTrackOnAlbum(t, pool, alW.ID, aWeb.ID, "TrkW")
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
insertPlayEventDevice(t, pool, u.ID, tM.ID, now, "mobile")
|
||||||
|
insertPlayEventDevice(t, pool, u.ID, tW.ID, now, "web")
|
||||||
|
}
|
||||||
|
|
||||||
|
mob, err := LoadContextAffinity(ctx, q, u.ID, "mobile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mobile: %v", err)
|
||||||
|
}
|
||||||
|
if mob.Affinity(aMobile.ID) <= mob.Affinity(aWeb.ID) {
|
||||||
|
t.Errorf("on mobile, mobile artist (%.3f) should out-score web artist (%.3f)",
|
||||||
|
mob.Affinity(aMobile.ID), mob.Affinity(aWeb.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
web, err := LoadContextAffinity(ctx, q, u.ID, "web")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("web: %v", err)
|
||||||
|
}
|
||||||
|
if web.Affinity(aWeb.ID) <= web.Affinity(aMobile.ID) {
|
||||||
|
t.Errorf("on web, web artist (%.3f) should out-score mobile artist (%.3f)",
|
||||||
|
web.Affinity(aWeb.ID), web.Affinity(aMobile.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device-agnostic ('' ): all plays fall in the same time cell, so the two
|
||||||
|
// artists are treated identically — the device dimension is what separates
|
||||||
|
// them above.
|
||||||
|
agn, err := LoadContextAffinity(ctx, q, u.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("agnostic: %v", err)
|
||||||
|
}
|
||||||
|
if agn.Affinity(aMobile.ID) != agn.Affinity(aWeb.ID) {
|
||||||
|
t.Errorf("device-agnostic affinities should match, got mobile=%.3f web=%.3f",
|
||||||
|
agn.Affinity(aMobile.ID), agn.Affinity(aWeb.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestContextAffinity(t *testing.T) {
|
||||||
|
const baseline = 0.4 // 40% of the user's plays fall in the current cell
|
||||||
|
const k = contextAffinityShrinkage
|
||||||
|
|
||||||
|
// Heavy history, over-represented in the current cell → positive.
|
||||||
|
if a := contextAffinity(80, 100, baseline, k); a <= 0 {
|
||||||
|
t.Errorf("over-represented artist affinity = %.3f, want positive", a)
|
||||||
|
}
|
||||||
|
// Heavy history, under-represented → negative.
|
||||||
|
if a := contextAffinity(10, 100, baseline, k); a >= 0 {
|
||||||
|
t.Errorf("under-represented artist affinity = %.3f, want negative", a)
|
||||||
|
}
|
||||||
|
// A sparse artist (1/1) shrinks toward the baseline, so its affinity is
|
||||||
|
// smaller than a heavily-played artist with the same raw cell-share.
|
||||||
|
sparse := contextAffinity(1, 1, baseline, k)
|
||||||
|
heavy := contextAffinity(100, 100, baseline, k)
|
||||||
|
if sparse >= heavy {
|
||||||
|
t.Errorf("sparse (%.3f) should shrink below heavy (%.3f)", sparse, heavy)
|
||||||
|
}
|
||||||
|
// No plays → neutral.
|
||||||
|
if a := contextAffinity(0, 0, baseline, k); a != 0 {
|
||||||
|
t.Errorf("no plays affinity = %.3f, want 0", a)
|
||||||
|
}
|
||||||
|
// Result stays within [-1, 1].
|
||||||
|
for _, tc := range [][2]float64{{100, 100}, {0, 100}, {50, 50}} {
|
||||||
|
a := contextAffinity(tc[0], tc[1], baseline, k)
|
||||||
|
if a < -1 || a > 1 {
|
||||||
|
t.Errorf("affinity out of [-1,1]: %.3f", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScore_ContextTermAddsAndSubtracts(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude
|
||||||
|
w := ScoringWeights{ContextTimeWeight: 2.0} // all other weights 0
|
||||||
|
|
||||||
|
pos := Score(ScoringInputs{ContextAffinityScore: 1.0}, w, now, zeroJitter)
|
||||||
|
if !almostEq(pos, 2.0) {
|
||||||
|
t.Errorf("positive context affinity: Score = %.3f, want 2.0", pos)
|
||||||
|
}
|
||||||
|
neg := Score(ScoringInputs{ContextAffinityScore: -1.0}, w, now, zeroJitter)
|
||||||
|
if !almostEq(neg, -2.0) {
|
||||||
|
t.Errorf("negative context affinity: Score = %.3f, want -2.0 (demotes)", neg)
|
||||||
|
}
|
||||||
|
off := Score(ScoringInputs{ContextAffinityScore: 1.0}, ScoringWeights{}, now, zeroJitter)
|
||||||
|
if !almostEq(off, 0.0) {
|
||||||
|
t.Errorf("ContextTimeWeight 0: Score = %.3f, want 0 (no effect)", off)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,19 +24,26 @@ type ScoringInputs struct {
|
|||||||
// user's taste, negative reflects passive avoidance, 0 when there's no
|
// user's taste, negative reflects passive avoidance, 0 when there's no
|
||||||
// profile signal (cold start / artist+tags absent from the profile).
|
// profile signal (cold start / artist+tags absent from the profile).
|
||||||
TasteMatchScore float64
|
TasteMatchScore float64
|
||||||
|
// ContextAffinityScore is the candidate artist's time-of-day/weekday
|
||||||
|
// affinity for the CURRENT context (#1531), in [-1, +1]: positive when the
|
||||||
|
// artist's plays concentrate in the current daypart × weekday-type cell
|
||||||
|
// more than the user's baseline, negative when under-represented, 0 when
|
||||||
|
// there's no history (cold-start neutral).
|
||||||
|
ContextAffinityScore float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||||
// config.RecommendationConfig and are propagated here per request.
|
// config.RecommendationConfig and are propagated here per request.
|
||||||
type ScoringWeights struct {
|
type ScoringWeights struct {
|
||||||
BaseWeight float64
|
BaseWeight float64
|
||||||
LikeBoost float64
|
LikeBoost float64
|
||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
SimilarityWeight float64
|
SimilarityWeight float64
|
||||||
TasteWeight float64
|
TasteWeight float64
|
||||||
|
ContextTimeWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score computes the weighted-shuffle score per spec §6:
|
// Score computes the weighted-shuffle score per spec §6:
|
||||||
@@ -48,6 +55,7 @@ type ScoringWeights struct {
|
|||||||
// + contextual_match_score * ContextWeight
|
// + contextual_match_score * ContextWeight
|
||||||
// + similarity_score * SimilarityWeight
|
// + similarity_score * SimilarityWeight
|
||||||
// + taste_match_score * TasteWeight
|
// + taste_match_score * TasteWeight
|
||||||
|
// + context_affinity_score * ContextTimeWeight
|
||||||
// + small_random_jitter
|
// + small_random_jitter
|
||||||
//
|
//
|
||||||
// Higher score = more likely to surface. rng is a function returning a
|
// Higher score = more likely to surface. rng is a function returning a
|
||||||
@@ -63,6 +71,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64
|
|||||||
s += in.ContextualMatchScore * w.ContextWeight
|
s += in.ContextualMatchScore * w.ContextWeight
|
||||||
s += in.SimilarityScore * w.SimilarityWeight
|
s += in.SimilarityScore * w.SimilarityWeight
|
||||||
s += in.TasteMatchScore * w.TasteWeight
|
s += in.TasteMatchScore * w.TasteWeight
|
||||||
|
s += in.ContextAffinityScore * w.ContextTimeWeight
|
||||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type SessionVector struct {
|
|||||||
Artists []string `json:"artists"`
|
Artists []string `json:"artists"`
|
||||||
Tags map[string]int `json:"tags"`
|
Tags map[string]int `json:"tags"`
|
||||||
RecentTrackIDs []string `json:"recent_track_ids"`
|
RecentTrackIDs []string `json:"recent_track_ids"`
|
||||||
|
// DeviceClass is the current request's device (#1551), set by the radio
|
||||||
|
// handler from the user's latest play; drives the device dimension of the
|
||||||
|
// context-affinity term. Empty (omitted) for the daily mixes and for the
|
||||||
|
// snapshot stored on play_events, so it never narrows those.
|
||||||
|
DeviceClass string `json:"device_class,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
|
func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
// suggestions.go is the M5c per-user artist-suggestion service. Reads
|
// suggestions.go is the per-user artist-suggestion service behind the
|
||||||
// the user's likes + plays, projects them through artist_similarity_unmatched
|
// Discover request surface. Seeds from the taste profile (falling back to
|
||||||
// via a single CTE, returns top-N candidates with top-3 attribution seeds
|
// likes + completed plays for a user who has none yet), projects those seeds
|
||||||
// resolved to artist names.
|
// through artist_similarity_unmatched via a single CTE, and returns top-N
|
||||||
|
// out-of-library candidates with top-3 attribution seeds resolved to names.
|
||||||
|
//
|
||||||
|
// Originally M5c, which seeded from raw likes + plays. That signal grew
|
||||||
|
// without bound and counted skips as engagement, so a few heavily-played
|
||||||
|
// artists monopolized every slot and the surface entrenched harder the more
|
||||||
|
// the user listened. Reworked to seed from the taste profile in issue #2367;
|
||||||
|
// see internal/db/queries/recommendation.sql for the tiering.
|
||||||
package recommendation
|
package recommendation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -20,6 +31,15 @@ type ArtistSuggestion struct {
|
|||||||
Name string
|
Name string
|
||||||
Score float64
|
Score float64
|
||||||
Attribution []SeedContribution
|
Attribution []SeedContribution
|
||||||
|
// MatchedTags are the candidate's own tags that overlap the user's taste
|
||||||
|
// profile, strongest first (max 3) — the "matches: shoegaze, melancholic"
|
||||||
|
// explanation (#2377). Empty when the candidate has no cached tags, which
|
||||||
|
// is common and not an error: coverage is permanently partial (#2376).
|
||||||
|
MatchedTags []string
|
||||||
|
// TagOverlap is the [0,1] share of the candidate's tag mass the user likes.
|
||||||
|
// Exposed for the admin tuning lab — seeing the term's actual distribution
|
||||||
|
// is how the operator picks a weight rather than guessing at one.
|
||||||
|
TagOverlap float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedContribution is one of the top-3 contributing seeds for a candidate.
|
// SeedContribution is one of the top-3 contributing seeds for a candidate.
|
||||||
@@ -32,9 +52,16 @@ type SeedContribution struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
||||||
// capped at 50 (default 12 when out of range); halfLifeDays is the
|
// capped at 50 (default 12 when out of range).
|
||||||
// recency-decay half-life for plays (default 30, operator-tunable).
|
//
|
||||||
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
|
// halfLifeDays (default 30) is the recency-decay half-life for the TIER-2
|
||||||
|
// seed path only — the likes + completed-plays fallback used while the user
|
||||||
|
// has no taste-profile rows yet. Once the profile is populated it seeds
|
||||||
|
// instead, carrying its own decay, so this knob stops applying.
|
||||||
|
func SuggestArtists(
|
||||||
|
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||||
|
halfLifeDays float64, limit int, tagOverlapWeight float64,
|
||||||
|
) ([]ArtistSuggestion, error) {
|
||||||
if limit <= 0 || limit > 50 {
|
if limit <= 0 || limit > 50 {
|
||||||
limit = 12
|
limit = 12
|
||||||
}
|
}
|
||||||
@@ -42,10 +69,14 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
|||||||
halfLifeDays = 30
|
halfLifeDays = 30
|
||||||
}
|
}
|
||||||
q := dbq.New(pool)
|
q := dbq.New(pool)
|
||||||
|
// Over-fetch so there is something to rotate through. A deterministic
|
||||||
|
// top-N over a score ordering shows the same faces every day until one is
|
||||||
|
// requested away — the tail of the ranking was unreachable (#2367
|
||||||
|
// mechanism 1). Selection from this pool happens in selectSuggestions.
|
||||||
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
|
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Column2: halfLifeDays,
|
Column2: halfLifeDays,
|
||||||
Limit: int32(limit),
|
Limit: int32(poolSizeFor(limit)),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("suggest: query: %w", err)
|
return nil, fmt.Errorf("suggest: query: %w", err)
|
||||||
@@ -98,5 +129,195 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
|||||||
Attribution: attribution,
|
Attribution: attribution,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
|
||||||
|
// Taste-tag term (#2377). Applied to the whole over-fetched pool BEFORE
|
||||||
|
// selection, so the rotation and diversity rules in selectSuggestions
|
||||||
|
// operate on taste-blended scores — boosting only the twelve already
|
||||||
|
// chosen by similarity would leave the actual re-ranking undone.
|
||||||
|
//
|
||||||
|
// A query error here is returned, NOT degraded past. Graceful degradation
|
||||||
|
// is for expected absence — no taste profile yet, no cached tags for a
|
||||||
|
// candidate — and both of those are handled explicitly as empty inputs
|
||||||
|
// below. A failing query is neither: swallowing it would hide a broken DB
|
||||||
|
// behind a subtly worse ranking that nothing reports.
|
||||||
|
tasteTags, candTags, err := loadTagInputs(ctx, q, userID, out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = applyTagOverlap(out, candTags, tasteTags, tagOverlapWeight)
|
||||||
|
|
||||||
|
return selectSuggestions(out, limit, rotationDay(time.Now())), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tasteTagLimit caps how many of the user's taste tags participate. The
|
||||||
|
// profile's long tail is near-zero weight and contributes nothing after
|
||||||
|
// normalization, so this bounds the query rather than the meaning.
|
||||||
|
const tasteTagLimit = 50
|
||||||
|
|
||||||
|
// loadTagInputs fetches both sides of the overlap comparison: the user's taste
|
||||||
|
// tags and the cached tags for exactly the candidates in this pool.
|
||||||
|
func loadTagInputs(
|
||||||
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, pool []ArtistSuggestion,
|
||||||
|
) (TagWeights, map[string]TagWeights, error) {
|
||||||
|
tasteRows, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{
|
||||||
|
UserID: userID,
|
||||||
|
Limit: tasteTagLimit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("suggest: taste tags: %w", err)
|
||||||
|
}
|
||||||
|
// No taste tags is a cold start, not a failure — return early and skip the
|
||||||
|
// candidate-tag fetch entirely, since nothing could match.
|
||||||
|
if len(tasteRows) == 0 {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
taste := make(TagWeights, len(tasteRows))
|
||||||
|
for _, r := range tasteRows {
|
||||||
|
taste[r.Tag] = r.Weight
|
||||||
|
}
|
||||||
|
|
||||||
|
mbids := make([]string, 0, len(pool))
|
||||||
|
for _, s := range pool {
|
||||||
|
mbids = append(mbids, s.MBID)
|
||||||
|
}
|
||||||
|
tagRows, err := q.ListCandidateArtistTagsForMbids(ctx, mbids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("suggest: candidate tags: %w", err)
|
||||||
|
}
|
||||||
|
byCandidate := make(map[string]TagWeights, len(pool))
|
||||||
|
for _, r := range tagRows {
|
||||||
|
if byCandidate[r.CandidateMbid] == nil {
|
||||||
|
byCandidate[r.CandidateMbid] = TagWeights{}
|
||||||
|
}
|
||||||
|
byCandidate[r.CandidateMbid][r.Tag] = r.Weight
|
||||||
|
}
|
||||||
|
return taste, byCandidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool multiplier: how many scored candidates to fetch per slot shown, so the
|
||||||
|
// rotation has somewhere to rotate. 4x keeps a day's deck genuinely different
|
||||||
|
// from yesterday's without pulling the whole long tail (whose scores are noise)
|
||||||
|
// into every request.
|
||||||
|
const suggestionPoolFactor = 4
|
||||||
|
|
||||||
|
// Hard ceiling on the fetched pool. The scored tail past this is low-signal, so
|
||||||
|
// paying for it would buy churn rather than better suggestions.
|
||||||
|
const suggestionPoolMax = 60
|
||||||
|
|
||||||
|
func poolSizeFor(limit int) int {
|
||||||
|
n := limit * suggestionPoolFactor
|
||||||
|
if n > suggestionPoolMax {
|
||||||
|
return suggestionPoolMax
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotationDay is the bucket the daily rotation hashes against. Server-local
|
||||||
|
// date, matching the `current_date` the Home rows already rotate on, so the
|
||||||
|
// whole product turns over at the same moment.
|
||||||
|
func rotationDay(now time.Time) string {
|
||||||
|
return now.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectSuggestions turns the scored pool into the response.
|
||||||
|
//
|
||||||
|
// Pure by design — no DB, no clock — so the rotation and diversity rules are
|
||||||
|
// unit-testable without Postgres. Callers pass the day bucket in.
|
||||||
|
//
|
||||||
|
// Three rules, in order:
|
||||||
|
//
|
||||||
|
// 1. Diversity cap. Walking in score order, a candidate is dropped once its
|
||||||
|
// dominant seed already owns maxPerSeed slots. Nothing capped this before,
|
||||||
|
// so all twelve slots could be neighbours of one artist and the surface
|
||||||
|
// read as a single narrow cluster (#2367 mechanism 4).
|
||||||
|
// 2. Head. The best few by score always lead, so the strongest matches are
|
||||||
|
// never rotated out of sight. Mirrors For You's head/tail shape.
|
||||||
|
// 3. Tail. The rest of the slots are drawn from the remaining pool ordered by
|
||||||
|
// md5(mbid + day) — the same daily-stable idiom the Home rows use. Stable
|
||||||
|
// within a day, different tomorrow, and it needs no stored state.
|
||||||
|
func selectSuggestions(pool []ArtistSuggestion, limit int, day string) []ArtistSuggestion {
|
||||||
|
if len(pool) <= limit {
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
preferred, overflow := partitionPerSeed(pool, maxPerSeedFor(limit))
|
||||||
|
|
||||||
|
headCount := limit / 3
|
||||||
|
if headCount > len(preferred) {
|
||||||
|
headCount = len(preferred)
|
||||||
|
}
|
||||||
|
out := make([]ArtistSuggestion, 0, limit)
|
||||||
|
out = append(out, preferred[:headCount]...)
|
||||||
|
|
||||||
|
// Hash once per candidate, not once per comparison.
|
||||||
|
rest := preferred[headCount:]
|
||||||
|
tail := make([]rotatable, 0, len(rest))
|
||||||
|
for _, s := range rest {
|
||||||
|
tail = append(tail, rotatable{key: rotationKey(s.MBID, day), suggestion: s})
|
||||||
|
}
|
||||||
|
sort.SliceStable(tail, func(i, j int) bool { return tail[i].key < tail[j].key })
|
||||||
|
for _, r := range tail {
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, r.suggestion)
|
||||||
|
}
|
||||||
|
// Diversity is a PREFERENCE, not a quota that may starve the deck. A user
|
||||||
|
// whose whole pool hangs off one or two seeds would otherwise get a
|
||||||
|
// three-card surface, which is worse than the monoculture we were avoiding
|
||||||
|
// (and is the vanish-or-nothing shape rule #131 exists to prevent). Top up
|
||||||
|
// in score order from what the cap set aside.
|
||||||
|
for _, s := range overflow {
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotatable pairs a candidate with its precomputed daily rotation key.
|
||||||
|
type rotatable struct {
|
||||||
|
key string
|
||||||
|
suggestion ArtistSuggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPerSeedFor keeps roughly a quarter of the deck attributable to any single
|
||||||
|
// seed artist — enough for a strong affinity to be well represented, not enough
|
||||||
|
// for it to BE the deck. Floored at 1 so a small limit still returns something.
|
||||||
|
func maxPerSeedFor(limit int) int {
|
||||||
|
n := limit / 4
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// partitionPerSeed splits the pool by whether each candidate's dominant
|
||||||
|
// (highest-contributing) seed still has allowance left. Input must be in score
|
||||||
|
// order; both outputs preserve it.
|
||||||
|
//
|
||||||
|
// Candidates with no attribution are always preferred — there is no seed to
|
||||||
|
// attribute them to, so they cannot be the cause of a monoculture.
|
||||||
|
func partitionPerSeed(pool []ArtistSuggestion, maxPerSeed int) (preferred, overflow []ArtistSuggestion) {
|
||||||
|
perSeed := make(map[pgtype.UUID]int, len(pool))
|
||||||
|
preferred = make([]ArtistSuggestion, 0, len(pool))
|
||||||
|
for _, s := range pool {
|
||||||
|
if len(s.Attribution) == 0 {
|
||||||
|
preferred = append(preferred, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dominant := s.Attribution[0].ArtistID
|
||||||
|
if perSeed[dominant] >= maxPerSeed {
|
||||||
|
overflow = append(overflow, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
perSeed[dominant]++
|
||||||
|
preferred = append(preferred, s)
|
||||||
|
}
|
||||||
|
return preferred, overflow
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotationKey(mbid, day string) string {
|
||||||
|
sum := md5.Sum([]byte(mbid + day))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) {
|
|||||||
seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9)
|
seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9)
|
||||||
seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5)
|
seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5)
|
||||||
|
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ func TestSuggestArtists_Top12Cap(t *testing.T) {
|
|||||||
for i := 0; i < 30; i++ {
|
for i := 0; i < 30; i++ {
|
||||||
seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01)
|
seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01)
|
||||||
}
|
}
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ func TestSuggestArtists_AttributionTopThree(t *testing.T) {
|
|||||||
likeArtist(t, pool, user.ID, seeds[i].ID)
|
likeArtist(t, pool, user.ID, seeds[i].ID)
|
||||||
seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1)
|
seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1)
|
||||||
}
|
}
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) {
|
|||||||
seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5)
|
seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5)
|
||||||
seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5)
|
seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5)
|
||||||
|
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) {
|
|||||||
seedArtist(t, pool, "InLib", inLibMBID)
|
seedArtist(t, pool, "InLib", inLibMBID)
|
||||||
seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9)
|
seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9)
|
||||||
|
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
|
|||||||
t.Fatalf("CreateLidarrRequest: %v", err)
|
t.Fatalf("CreateLidarrRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -310,7 +310,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
|
|||||||
t.Fatalf("RejectLidarrRequest: %v", err)
|
t.Fatalf("RejectLidarrRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -322,7 +322,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
|
|||||||
func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
user := seedUser(t, pool, "newbie")
|
user := seedUser(t, pool, "newbie")
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -330,3 +330,359 @@ func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
|||||||
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
|
||||||
|
|
||||||
|
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`,
|
||||||
|
userID, artistID, weight,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("set taste weight: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertSkippedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var sessionID pgtype.UUID
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||||
|
VALUES ($1, $2, $2, 'skip-test') RETURNING id`,
|
||||||
|
userID, startedAt,
|
||||||
|
).Scan(&sessionID); err != nil {
|
||||||
|
t.Fatalf("insert play_session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
|
||||||
|
VALUES ($1, $2, $3, $4, true)`,
|
||||||
|
userID, trackID, sessionID, startedAt,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert skipped play_event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
|
||||||
|
// play on the seed artist. Before #2367 the profile was ignored entirely here.
|
||||||
|
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seed := seedArtist(t, pool, "Profile Seed", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (taste weight alone should seed)", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "out-mbid" {
|
||||||
|
t.Errorf("mbid = %q, want out-mbid", out[0].MBID)
|
||||||
|
}
|
||||||
|
if out[0].Score <= 0 {
|
||||||
|
t.Errorf("score = %v, want > 0", out[0].Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the profile has any positive row, tier 2 is not consulted — so an artist
|
||||||
|
// the user played but that the taste engine did not keep does NOT seed. That is
|
||||||
|
// the point of the rewrite: the taste engine decides what counts as affinity.
|
||||||
|
func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
kept := seedArtist(t, pool, "Kept By Taste", "")
|
||||||
|
dropped := seedArtist(t, pool, "Dropped By Taste", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, kept.ID, 3.0)
|
||||||
|
|
||||||
|
// `dropped` has real completed plays but no profile row.
|
||||||
|
album := seedAlbumForArtist(t, pool, dropped.ID, "Album")
|
||||||
|
track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track")
|
||||||
|
insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour))
|
||||||
|
|
||||||
|
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
|
||||||
|
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (tier 2 must not run alongside tier 1)", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "kept-cand" {
|
||||||
|
t.Errorf("mbid = %q, want kept-cand", out[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-positive taste weight is not affinity. Guarded by a second, positive
|
||||||
|
// row so tier 1 stays active — otherwise an empty tier 1 would fall through to
|
||||||
|
// tier 2 and the assertion would pass for the wrong reason.
|
||||||
|
func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
positive := seedArtist(t, pool, "Still Liked", "")
|
||||||
|
abandoned := seedArtist(t, pool, "Abandoned", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, positive.ID, 2.0)
|
||||||
|
setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5)
|
||||||
|
|
||||||
|
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
|
||||||
|
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range out {
|
||||||
|
if s.MBID == "neg-cand" {
|
||||||
|
t.Fatalf("negative-weight artist seeded a suggestion: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) != 1 || out[0].MBID != "pos-cand" {
|
||||||
|
t.Errorf("out = %+v, want only pos-cand", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
|
||||||
|
// skipping an artist repeatedly increased its signal and pushed more of its
|
||||||
|
// neighbours at the user (#2367 mechanism 3).
|
||||||
|
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
skipped := seedArtist(t, pool, "Only Skipped", "")
|
||||||
|
|
||||||
|
album := seedAlbumForArtist(t, pool, skipped.ID, "Album")
|
||||||
|
track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour))
|
||||||
|
}
|
||||||
|
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0 (skips are not affinity): %+v", len(out), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Slice 3 (#2374): time-boxed suggestion snooze ---
|
||||||
|
//
|
||||||
|
// The defining property under test is that a snooze EXPIRES. A permanent
|
||||||
|
// dismissal would pass most of these; only TestSuggestArtists_ExpiredSnooze
|
||||||
|
// distinguishes the two, and it is the reason this shape was approved over an
|
||||||
|
// exclusion UI (rule #101).
|
||||||
|
|
||||||
|
// snoozeUntil inserts a snooze row with an explicit absolute expiry, so a
|
||||||
|
// test can place it in the past without depending on the duration arithmetic
|
||||||
|
// in SnoozeSuggestion.
|
||||||
|
func snoozeUntil(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, mbid, name string, until time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (user_id, candidate_mbid) DO UPDATE SET snoozed_until = EXCLUDED.snoozed_until`,
|
||||||
|
userID, mbid, name, until,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("snoozeUntil: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedOneCandidate wires the minimum that puts exactly one candidate in the
|
||||||
|
// deck: a liked seed artist with one unmatched neighbour.
|
||||||
|
func seedOneCandidate(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, mbid, name string) {
|
||||||
|
t.Helper()
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
likeArtist(t, pool, userID, seed.ID)
|
||||||
|
seedUnmatched(t, pool, seed.ID, mbid, name, 0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuggestArtists_ActiveSnoozeHidesCandidate(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seedOneCandidate(t, pool, user.ID, "snoozed-mbid", "Parked Artist")
|
||||||
|
|
||||||
|
if err := dbq.New(pool).SnoozeSuggestion(context.Background(), dbq.SnoozeSuggestionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CandidateMbid: "snoozed-mbid",
|
||||||
|
CandidateName: "Parked Artist",
|
||||||
|
Column4: 90,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SnoozeSuggestion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0 (active snooze should hide the candidate): %+v", len(out), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of a snooze over a dismissal: it comes back on its own.
|
||||||
|
func TestSuggestArtists_ExpiredSnoozeShowsCandidateAgain(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seedOneCandidate(t, pool, user.ID, "expired-mbid", "Returning Artist")
|
||||||
|
snoozeUntil(t, pool, user.ID, "expired-mbid", "Returning Artist", time.Now().Add(-time.Hour))
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (an expired snooze must not hide anything): %+v", len(out), out)
|
||||||
|
}
|
||||||
|
if out[0].MBID != "expired-mbid" {
|
||||||
|
t.Errorf("mbid = %q, want expired-mbid", out[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule #47: one household member parking a suggestion must not remove it
|
||||||
|
// from anyone else's deck.
|
||||||
|
func TestSuggestArtists_SnoozeIsPerUser(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
alice := seedUser(t, pool, "alice")
|
||||||
|
bob := seedUser(t, pool, "bob")
|
||||||
|
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
likeArtist(t, pool, alice.ID, seed.ID)
|
||||||
|
likeArtist(t, pool, bob.ID, seed.ID)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "shared-mbid", "Shared Candidate", 0.9)
|
||||||
|
|
||||||
|
snoozeUntil(t, pool, alice.ID, "shared-mbid", "Shared Candidate", time.Now().Add(24*time.Hour))
|
||||||
|
|
||||||
|
aliceOut, err := SuggestArtists(context.Background(), pool, alice.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists(alice): %v", err)
|
||||||
|
}
|
||||||
|
if len(aliceOut) != 0 {
|
||||||
|
t.Errorf("alice len = %d, want 0 (she snoozed it)", len(aliceOut))
|
||||||
|
}
|
||||||
|
bobOut, err := SuggestArtists(context.Background(), pool, bob.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists(bob): %v", err)
|
||||||
|
}
|
||||||
|
if len(bobOut) != 1 {
|
||||||
|
t.Errorf("bob len = %d, want 1 (alice's snooze is not his)", len(bobOut))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsnoozeSuggestion_RestoresImmediately(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seedOneCandidate(t, pool, user.ID, "undo-mbid", "Undo Artist")
|
||||||
|
snoozeUntil(t, pool, user.ID, "undo-mbid", "Undo Artist", time.Now().Add(90*24*time.Hour))
|
||||||
|
|
||||||
|
q := dbq.New(pool)
|
||||||
|
rows, err := q.UnsnoozeSuggestion(context.Background(), dbq.UnsnoozeSuggestionParams{
|
||||||
|
UserID: user.ID, CandidateMbid: "undo-mbid",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnsnoozeSuggestion: %v", err)
|
||||||
|
}
|
||||||
|
if rows != 1 {
|
||||||
|
t.Errorf("rows = %d, want 1", rows)
|
||||||
|
}
|
||||||
|
// A second delete affects nothing — the handler turns this into a 404
|
||||||
|
// rather than reporting success for a no-op.
|
||||||
|
rows, err = q.UnsnoozeSuggestion(context.Background(), dbq.UnsnoozeSuggestionParams{
|
||||||
|
UserID: user.ID, CandidateMbid: "undo-mbid",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnsnoozeSuggestion (repeat): %v", err)
|
||||||
|
}
|
||||||
|
if rows != 0 {
|
||||||
|
t.Errorf("repeat rows = %d, want 0", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Errorf("len = %d, want 1 (unsnooze restores the candidate)", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-snoozing must extend, not conflict on the PK.
|
||||||
|
func TestSnoozeSuggestion_UpsertExtends(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
snoozeUntil(t, pool, user.ID, "extend-mbid", "Old Name", time.Now().Add(time.Hour))
|
||||||
|
if err := q.SnoozeSuggestion(context.Background(), dbq.SnoozeSuggestionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CandidateMbid: "extend-mbid",
|
||||||
|
CandidateName: "New Name",
|
||||||
|
Column4: 90,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SnoozeSuggestion (re-snooze): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.ListActiveSuggestionSnoozes(context.Background(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (upsert, not a second row)", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].CandidateName != "New Name" {
|
||||||
|
t.Errorf("name = %q, want New Name (upsert refreshes it)", rows[0].CandidateName)
|
||||||
|
}
|
||||||
|
if got := time.Until(rows[0].SnoozedUntil.Time); got < 80*24*time.Hour {
|
||||||
|
t.Errorf("snoozed_until is %v away, want ~90d (re-snooze should extend)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveSuggestionSnoozes filters expired rows itself rather than
|
||||||
|
// trusting the hourly gc sweep to have run.
|
||||||
|
func TestListActiveSuggestionSnoozes_ExcludesExpired(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
snoozeUntil(t, pool, user.ID, "live-mbid", "Live", time.Now().Add(24*time.Hour))
|
||||||
|
snoozeUntil(t, pool, user.ID, "dead-mbid", "Dead", time.Now().Add(-24*time.Hour))
|
||||||
|
|
||||||
|
rows, err := dbq.New(pool).ListActiveSuggestionSnoozes(context.Background(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (expired row must not be listed)", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].CandidateMbid != "live-mbid" {
|
||||||
|
t.Errorf("mbid = %q, want live-mbid", rows[0].CandidateMbid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGcDeleteExpiredSuggestionSnoozes_KeepsActive(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
snoozeUntil(t, pool, user.ID, "live-mbid", "Live", time.Now().Add(24*time.Hour))
|
||||||
|
snoozeUntil(t, pool, user.ID, "dead-mbid", "Dead", time.Now().Add(-24*time.Hour))
|
||||||
|
|
||||||
|
q := dbq.New(pool)
|
||||||
|
deleted, err := q.GcDeleteExpiredSuggestionSnoozes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GcDeleteExpiredSuggestionSnoozes: %v", err)
|
||||||
|
}
|
||||||
|
if deleted != 1 {
|
||||||
|
t.Errorf("deleted = %d, want 1 (only the expired row)", deleted)
|
||||||
|
}
|
||||||
|
rows, err := q.ListActiveSuggestionSnoozes(context.Background(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Errorf("len = %d, want 1 (active snooze survives the sweep)", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user