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
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
# Gitea Actions runs in GHES-emulation mode; @actions/artifact v2+
|
||||
# (i.e. upload-artifact@v4+) errors with "GHESNotSupportedError".
|
||||
# Pin to @v3 until act_runner or the artifact backend catches up.
|
||||
uses: actions/upload-artifact@v3
|
||||
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||
# 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:
|
||||
name: minstrel-android-debug-${{ github.sha }}
|
||||
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 }}
|
||||
|
||||
- name: Upload APK as workflow artifact
|
||||
# @v3 because Gitea Actions emulates GHES and the v2 artifact
|
||||
# backend used by upload-artifact@v4 errors with GHESNotSupportedError.
|
||||
uses: actions/upload-artifact@v3
|
||||
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||
# 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:
|
||||
name: minstrel-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
|
||||
shell: bash
|
||||
@@ -238,7 +245,20 @@ jobs:
|
||||
# Tag pushes only — android-release just produced this. Non-tag
|
||||
# builds take the "Bundle latest release APK" path below instead.
|
||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||
uses: actions/download-artifact@v3
|
||||
# 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:
|
||||
name: minstrel-apk
|
||||
path: client/
|
||||
|
||||
@@ -27,6 +27,7 @@ on:
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- 'sqlc.yaml'
|
||||
- 'Makefile'
|
||||
- 'internal/**'
|
||||
- 'cmd/**'
|
||||
- '.golangci.yml'
|
||||
@@ -53,6 +54,9 @@ jobs:
|
||||
go version
|
||||
golangci-lint --version
|
||||
|
||||
- name: Generated code matches queries (sqlc)
|
||||
run: make verify-generate
|
||||
|
||||
- name: 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
|
||||
|
||||
# Local codegen. Containerised so a dev needs no sqlc install.
|
||||
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:
|
||||
go test -race ./...
|
||||
|
||||
|
||||
@@ -210,4 +210,17 @@ dependencies {
|
||||
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.SingletonImageLoader
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import coil3.request.crossfade
|
||||
import com.fabledsword.minstrel.cache.CacheIndexer
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||
@@ -29,6 +30,10 @@ import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
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
|
||||
class MinstrelApplication :
|
||||
Application(),
|
||||
@@ -213,11 +218,18 @@ class MinstrelApplication :
|
||||
* OkHttp client as the network fetcher. The `callFactory` lambda
|
||||
* is invoked lazily so Hilt has time to inject `okHttpClient`
|
||||
* 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 =
|
||||
ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
||||
}
|
||||
.crossfade(ART_CROSSFADE_MS)
|
||||
.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 com.composables.icons.lucide.Inbox
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.composables.icons.lucide.TriangleAlert
|
||||
import com.composables.icons.lucide.Users
|
||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||
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.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||
import com.fabledsword.minstrel.nav.AdminUsers
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -54,7 +57,7 @@ import javax.inject.Inject
|
||||
|
||||
// ─── 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 {
|
||||
data object Loading : AdminLandingUiState
|
||||
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
|
||||
private val requestsRepo: AdminRequestsRepository,
|
||||
private val quarantineRepo: AdminQuarantineRepository,
|
||||
private val usersRepo: AdminUsersRepository,
|
||||
private val tagSourcesRepo: AdminTagSourcesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
|
||||
val req = async { requestsRepo.list().size }
|
||||
val qua = async { quarantineRepo.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)
|
||||
} catch (
|
||||
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
||||
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.CreateRequestBody
|
||||
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.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
@@ -30,4 +34,30 @@ interface DiscoverApi {
|
||||
|
||||
@POST("api/requests")
|
||||
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.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -21,12 +22,32 @@ interface CachedHomeIndexDao {
|
||||
)
|
||||
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)
|
||||
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
|
||||
|
||||
/** Replace-all pattern; sync wipes a section then re-inserts. */
|
||||
@Query("DELETE FROM cached_home_index WHERE section = :section")
|
||||
suspend fun deleteBySection(section: String)
|
||||
@Query("DELETE FROM cached_home_index WHERE section IN (:sections)")
|
||||
suspend fun deleteSections(sections: List<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")
|
||||
suspend fun clear()
|
||||
|
||||
+25
-1
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import androidx.room.Transaction
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -35,10 +36,33 @@ interface CachedPlaylistTrackDao {
|
||||
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||
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")
|
||||
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(
|
||||
"DELETE FROM cached_playlist_tracks " +
|
||||
"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
|
||||
// path produced (see 2026-06-11 contract audit).
|
||||
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(
|
||||
MutationKind.REQUEST_CANCEL,
|
||||
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
|
||||
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
||||
@@ -239,6 +279,9 @@ data class PlayOfflinePayload(
|
||||
val atIso: String,
|
||||
val durationPlayedMs: Long,
|
||||
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.models.wire.PlayEndedRequest
|
||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||
@@ -114,11 +115,12 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private suspend fun drain() {
|
||||
val rows = dao.getAll()
|
||||
// Collapse superseded like-toggles: only the latest desired state per
|
||||
// (entity) is replayed; older toggles for the same entity are dropped
|
||||
// unsent. Without this, partial-failure + differential retry could
|
||||
// replay an older toggle last and invert the final like state.
|
||||
val superseded = supersededLikeToggleIds(rows)
|
||||
// Collapse superseded toggles (likes, suggestion snoozes): only the
|
||||
// latest desired state per entity is replayed; older toggles for the
|
||||
// same entity are dropped unsent. Without this, partial-failure +
|
||||
// differential retry could replay an older toggle last and invert the
|
||||
// final state — a snooze the user already undid would come back.
|
||||
val superseded = supersededToggleIds(rows, json)
|
||||
for (row in rows) {
|
||||
if (row.id in superseded) {
|
||||
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 {
|
||||
dispatch(row)
|
||||
} catch (e: HttpException) {
|
||||
@@ -182,6 +165,7 @@ class MutationReplayer @Inject constructor(
|
||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(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.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
@@ -254,6 +238,7 @@ class MutationReplayer @Inject constructor(
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
deviceClass = decoded.deviceClass,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
@@ -276,6 +261,24 @@ class MutationReplayer @Inject constructor(
|
||||
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 {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
playbackErrorsApi.report(
|
||||
@@ -296,3 +299,46 @@ class MutationReplayer @Inject constructor(
|
||||
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.LidarrSearchResultRef
|
||||
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.CreateRequestBody
|
||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||
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.create
|
||||
import javax.inject.Inject
|
||||
@@ -46,6 +50,69 @@ class DiscoverRepository @Inject constructor(
|
||||
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
||||
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> =
|
||||
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||
|
||||
@@ -85,6 +152,13 @@ class DiscoverRepository @Inject constructor(
|
||||
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) ──
|
||||
@@ -105,6 +179,7 @@ private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggest
|
||||
name = name,
|
||||
imageUrl = imageUrl,
|
||||
attribution = attribution.map { it.toDomain() },
|
||||
matchedTags = matchedTags,
|
||||
)
|
||||
|
||||
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
||||
@@ -112,6 +187,12 @@ private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContribut
|
||||
isLiked = isLiked,
|
||||
)
|
||||
|
||||
private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
|
||||
mbid = mbid,
|
||||
name = name,
|
||||
snoozedUntil = snoozedUntil,
|
||||
)
|
||||
|
||||
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
||||
kind = kind,
|
||||
artistMbid = artistMbid,
|
||||
|
||||
@@ -40,6 +40,7 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import com.fabledsword.minstrel.nav.Discover
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -104,6 +105,18 @@ private fun DiscoverBody(
|
||||
ResultsState.Idle -> SuggestionsPane(
|
||||
state = state.suggestions,
|
||||
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 ->
|
||||
scope.launch {
|
||||
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
|
||||
private fun SuggestionsPane(
|
||||
state: SuggestionState,
|
||||
locallyRequestedMbids: Set<String>,
|
||||
snoozeUi: SnoozeUi,
|
||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
@@ -194,6 +220,7 @@ private fun SuggestionsPane(
|
||||
)
|
||||
is SuggestionState.Loaded -> SuggestionsList(
|
||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||
snoozeUi = snoozeUi,
|
||||
onRequest = onRequest,
|
||||
)
|
||||
}
|
||||
@@ -202,6 +229,7 @@ private fun SuggestionsPane(
|
||||
@Composable
|
||||
private fun SuggestionsList(
|
||||
items: List<ArtistSuggestionRef>,
|
||||
snoozeUi: SnoozeUi,
|
||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
@@ -210,13 +238,61 @@ private fun SuggestionsList(
|
||||
) {
|
||||
item { SuggestionsHeader() }
|
||||
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 {
|
||||
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()
|
||||
}
|
||||
}
|
||||
// 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.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
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.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.composables.icons.lucide.Clock
|
||||
import com.composables.icons.lucide.Disc3
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.User
|
||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
|
||||
@Composable
|
||||
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
||||
internal fun SuggestionTile(
|
||||
s: ArtistSuggestionRef,
|
||||
snoozed: Boolean,
|
||||
onRequest: () -> Unit,
|
||||
onSnooze: () -> Unit,
|
||||
onUnsnooze: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -48,9 +58,13 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
||||
maxLines = 1,
|
||||
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 = s.attributionText,
|
||||
text = secondary,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
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.LidarrRequestKind
|
||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -27,6 +28,17 @@ data class DiscoverState(
|
||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||
val results: ResultsState = ResultsState.Idle,
|
||||
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 {
|
||||
@@ -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() {
|
||||
|
||||
@@ -14,8 +14,10 @@ import com.fabledsword.minstrel.models.TrackRef
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -34,9 +36,10 @@ import javax.inject.Singleton
|
||||
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
||||
* per-item tile providers.
|
||||
*
|
||||
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section
|
||||
* in-place (delete-then-insert, so the section Flows re-fire), and
|
||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
||||
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
|
||||
* one transaction (so the rows update together in a single emission
|
||||
* rather than collapsing and refilling), and pre-warms the top artists
|
||||
* via [HomeArtistPrewarmer].
|
||||
*/
|
||||
@Singleton
|
||||
// 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>>> =
|
||||
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,
|
||||
* and pre-warms the top artists. The section Flows re-fire on the
|
||||
* index change; missing entity rows hydrate via the on-miss path.
|
||||
* Pulls /api/home/index and swaps every cached_home_index section in
|
||||
* a single transaction, then pre-warms the top artists. Missing
|
||||
* 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() {
|
||||
val wire = api.getHomeIndex()
|
||||
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums)
|
||||
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums)
|
||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
|
||||
homeIndexDao.replaceSections(
|
||||
sections = ALL_SECTIONS,
|
||||
rows = rowsFor(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) +
|
||||
rowsFor(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) +
|
||||
rowsFor(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) +
|
||||
rowsFor(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) +
|
||||
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(
|
||||
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
||||
homeIndexDao.deleteBySection(section)
|
||||
if (ids.isEmpty()) return
|
||||
homeIndexDao.upsertAll(
|
||||
ids.mapIndexed { index, id ->
|
||||
CachedHomeIndexEntity(
|
||||
section = section,
|
||||
position = index,
|
||||
entityType = entityType,
|
||||
entityId = id,
|
||||
)
|
||||
},
|
||||
private fun rowsFor(
|
||||
section: String,
|
||||
entityType: String,
|
||||
ids: List<String>,
|
||||
): List<CachedHomeIndexEntity> = ids.mapIndexed { index, id ->
|
||||
CachedHomeIndexEntity(
|
||||
section = section,
|
||||
position = index,
|
||||
entityType = entityType,
|
||||
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)
|
||||
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
||||
if (rows.isEmpty()) {
|
||||
observeSectionIds(section).flatMapLatest { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
|
||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
||||
combine(ids.map { metadataProvider.observeAlbum(it) }) { refs ->
|
||||
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
||||
if (rows.isEmpty()) {
|
||||
observeSectionIds(section).flatMapLatest { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
|
||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
||||
combine(ids.map { metadataProvider.observeArtist(it) }) { refs ->
|
||||
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
||||
if (rows.isEmpty()) {
|
||||
observeSectionIds(section).flatMapLatest { ids ->
|
||||
if (ids.isEmpty()) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
|
||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
||||
combine(ids.map { metadataProvider.observeTrack(it) }) { refs ->
|
||||
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_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
||||
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.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
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.PlaylistPlaceholderCard
|
||||
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.widgets.ArtSettleTracker
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
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.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import javax.inject.Inject
|
||||
|
||||
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_HEIGHT_DP = 440
|
||||
|
||||
// "Updating your mixes…" veil (automatic refresh). Held through the pull
|
||||
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
|
||||
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
||||
private const val VEIL_SETTLE_MS = 500L
|
||||
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
|
||||
// appears at all — only when a refresh actually changes something — and how
|
||||
// long it stays, by watching the screen settle rather than by a fixed delay,
|
||||
// which lowered it while tiles and artwork were still landing (#2327).
|
||||
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
||||
private const val VEIL_WIPE_MS = 280
|
||||
private const val VEIL_ALPHA = 0.96f
|
||||
private const val VEIL_SPINNER_DP = 22
|
||||
private const val VEIL_SPINNER_STROKE_DP = 2
|
||||
private const val VEIL_LABEL_GAP_DP = 12
|
||||
|
||||
// Backstop on how long a manual pull keeps its own indicator while waiting
|
||||
// for its successor — the veil, or the "already up to date" snackbar — so the
|
||||
// two never both vanish for a frame mid-handoff.
|
||||
private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
data class HomeSections(
|
||||
@@ -184,10 +199,13 @@ class HomeViewModel @Inject constructor(
|
||||
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
|
||||
@@ -197,38 +215,13 @@ class HomeViewModel @Inject constructor(
|
||||
*/
|
||||
private val refreshError = MutableStateFlow<String?>(null)
|
||||
|
||||
private val updatingInternal = MutableStateFlow(false)
|
||||
|
||||
/**
|
||||
* True while an automatic background refresh (the 03:00 daily rebuild
|
||||
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
|
||||
* your mixes…" veil so the section churn — delete-then-insert in
|
||||
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
|
||||
* hidden behind the veil instead of on screen. Manual pull-to-refresh
|
||||
* and cold start are NOT veiled (they own the pull spinner / skeleton).
|
||||
* Cover-art loads in flight on Home, reported by every [ServerImage]
|
||||
* under [LocalArtSettleTracker]. The veil waits on this so artwork
|
||||
* arriving a beat after its tile lands behind the veil rather than
|
||||
* popping in on screen.
|
||||
*/
|
||||
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
|
||||
|
||||
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() }
|
||||
}
|
||||
}
|
||||
val artTracker = ArtSettleTracker()
|
||||
|
||||
/**
|
||||
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
||||
@@ -241,7 +234,7 @@ class HomeViewModel @Inject constructor(
|
||||
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
||||
}.shuffled()
|
||||
if (tracks.isEmpty()) {
|
||||
poolMessages.trySend("No cached ${kind.label} tracks yet")
|
||||
snackbarMessages.trySend("No cached ${kind.label} tracks yet")
|
||||
} else {
|
||||
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
||||
}
|
||||
@@ -278,14 +271,14 @@ class HomeViewModel @Inject constructor(
|
||||
try {
|
||||
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
||||
if (detail.tracks.isEmpty()) {
|
||||
poolMessages.trySend("This album has no tracks to play.")
|
||||
snackbarMessages.trySend("This album has no tracks to play.")
|
||||
} else {
|
||||
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
poolMessages.trySend(
|
||||
snackbarMessages.trySend(
|
||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
@@ -303,14 +296,14 @@ class HomeViewModel @Inject constructor(
|
||||
try {
|
||||
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
||||
if (tracks.isEmpty()) {
|
||||
poolMessages.trySend("This artist has no tracks to play.")
|
||||
snackbarMessages.trySend("This artist has no tracks to play.")
|
||||
} else {
|
||||
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
poolMessages.trySend(
|
||||
snackbarMessages.trySend(
|
||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||
)
|
||||
}
|
||||
@@ -328,52 +321,66 @@ class HomeViewModel @Inject constructor(
|
||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||
viewModelScope.launch {
|
||||
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
||||
poolMessages.trySend(it)
|
||||
snackbarMessages.trySend(it)
|
||||
}
|
||||
}.join()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls both /home/index and the playlists list. Returns the Job
|
||||
* for the combined refresh so a pull-to-refresh wrapper can await
|
||||
* actual completion before hiding the indicator.
|
||||
* Pulls /home/index, the playlists list and the system-playlist
|
||||
* status. Returns true when the load-bearing /home/index pull
|
||||
* 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 {
|
||||
refreshError.value = null
|
||||
val home = launch {
|
||||
// /home/index is the load-bearing pull: its failure drives the
|
||||
// empty-cache Error state. A failure over a populated cache
|
||||
// stays silent — cached sections beat a full-screen error.
|
||||
private suspend fun runRefresh(): Boolean = coroutineScope {
|
||||
// /home/index is the load-bearing pull: its failure drives the
|
||||
// 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() }
|
||||
.onSuccess { refreshError.value = null }
|
||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||
.isSuccess
|
||||
}
|
||||
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||
val status = launch {
|
||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||
.onSuccess { systemStatusInternal.value = it }
|
||||
}
|
||||
home.join()
|
||||
lists.join()
|
||||
status.join()
|
||||
home.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatic background refresh with the "Updating your mixes…" veil
|
||||
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
|
||||
* paths where Home is already on screen. Holds the veil through the
|
||||
* 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.
|
||||
* The Error state's explicit Retry button. User-initiated, so it gets
|
||||
* the controller's retries and reports its outcome; over an empty cache
|
||||
* there's no content to protect, so no veil goes up.
|
||||
*/
|
||||
private fun refreshBehindVeil() {
|
||||
viewModelScope.launch {
|
||||
updatingInternal.value = true
|
||||
try {
|
||||
refresh().join()
|
||||
delay(VEIL_SETTLE_MS)
|
||||
} finally {
|
||||
updatingInternal.value = false
|
||||
}
|
||||
fun retry() = veil.request(userInitiated = true)
|
||||
|
||||
/**
|
||||
* Manual pull-to-refresh. Goes behind the veil like every other refresh
|
||||
* (operator call, 2026-07-31: the churn a pull causes is identical to
|
||||
* the automatic paths, and a small spinner didn't hide it).
|
||||
*
|
||||
* Suspends until the veil has taken over OR the session has finished,
|
||||
* so the pull indicator hands off to exactly one successor: the veil if
|
||||
* content changed, the "Already up to date" snackbar if it didn't. The
|
||||
* timeout is only a backstop against a session that outlives it.
|
||||
*/
|
||||
suspend fun refreshFromPull() {
|
||||
val before = veil.finishedSessions.value
|
||||
veil.request(userInitiated = true)
|
||||
withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) {
|
||||
combine(veil.visible, veil.finishedSessions) { veiled, finished ->
|
||||
veiled || finished != before
|
||||
}.first { it }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +432,124 @@ class HomeViewModel @Inject constructor(
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
@@ -454,14 +579,20 @@ fun HomeScreen(
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
onRefresh = { viewModel.refreshFromPull() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||
// Automatic-refresh veil: the daily rebuild / reconnect
|
||||
// churn hides behind an "Updating your mixes…" wipe. Manual
|
||||
// pull owns the PullToRefreshBox spinner instead.
|
||||
// Every cover below reports its load state to the tracker,
|
||||
// so the veil can wait for artwork instead of guessing.
|
||||
CompositionLocalProvider(
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -499,7 +630,7 @@ private fun HomeStateCrossfade(
|
||||
is UiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load home",
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
onRetry = { viewModel.retry() },
|
||||
)
|
||||
is UiState.Success -> HomeSuccessContent(
|
||||
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
|
||||
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Kind of Lidarr request being created. Wire form is the lowercase
|
||||
* enum name; the helper [wire] keeps that mapping in one place.
|
||||
@@ -49,6 +52,8 @@ data class ArtistSuggestionRef(
|
||||
val name: String,
|
||||
val imageUrl: String = "",
|
||||
val attribution: List<SeedContributionRef> = emptyList(),
|
||||
/** Taste-profile tags this candidate matches, strongest first (#2377). */
|
||||
val matchedTags: List<String> = emptyList(),
|
||||
) {
|
||||
val attributionText: String
|
||||
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 {
|
||||
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 = "",
|
||||
@SerialName("image_url") val imageUrl: String = "",
|
||||
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,
|
||||
)
|
||||
|
||||
/**
|
||||
* 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`
|
||||
* payload shape. Optional fields are emitted only when non-null
|
||||
|
||||
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
val source: String? = null,
|
||||
// #1551: device class for context conditioning (server normalizes).
|
||||
@SerialName("device_class") val deviceClass: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
|
||||
val at: String,
|
||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||
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.AdminQuarantineScreen
|
||||
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.auth.ui.LoginScreen
|
||||
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) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable data object AdminRequests
|
||||
@Serializable data object AdminQuarantine
|
||||
@Serializable data object AdminUsers
|
||||
@Serializable data object AdminTagSources
|
||||
|
||||
// ── 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.TransportState
|
||||
import java.io.IOException
|
||||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -58,8 +59,10 @@ import timber.log.Timber
|
||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||
* advance natively. PollLoop syncs the local cursor by comparing the
|
||||
* 1-based Track index from GetPositionInfo.
|
||||
* advance natively. PollLoop keeps the local cursor aligned to the
|
||||
* 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(
|
||||
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 =
|
||||
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
||||
|
||||
@@ -526,18 +546,8 @@ class MinstrelForwardingPlayer(
|
||||
|
||||
/**
|
||||
* One poll tick: read position + transport state from Sonos, apply to
|
||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||
* index when not in queue load.
|
||||
*
|
||||
* 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.
|
||||
* [remoteState], and sync the local cursor to the track the renderer is
|
||||
* actually playing (see [syncLocalCursorToRemote]).
|
||||
*/
|
||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||
val info = active.avTransport.getPositionInfo()
|
||||
@@ -553,7 +563,7 @@ class MinstrelForwardingPlayer(
|
||||
trackUri = info.trackUri,
|
||||
trackNumber = info.track,
|
||||
)
|
||||
maybeSyncLocalCursor(info.track)
|
||||
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||
val transport = active.avTransport.getTransportInfo()
|
||||
when (transport.state) {
|
||||
TransportState.PLAYING -> {
|
||||
@@ -577,23 +587,95 @@ class MinstrelForwardingPlayer(
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
|
||||
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
||||
if (holder.target.value != null) return
|
||||
if (sonosTrack <= 0) return
|
||||
val sonosIdx = sonosTrack - 1
|
||||
/**
|
||||
* Align the paused local delegate cursor to the track the renderer is
|
||||
* actually playing, so the un-overridden current-item getters
|
||||
* (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 {
|
||||
val localIdx = delegate.currentMediaItemIndex
|
||||
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
||||
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
|
||||
if (target != delegate.currentMediaItemIndex) {
|
||||
Timber.w(
|
||||
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
||||
localIdx, sonosIdx,
|
||||
"UPnP cursor sync: local=%d -> %d",
|
||||
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 {
|
||||
// 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 NON_PLAYING_CONFIRM = 2
|
||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||
|
||||
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
trackId = trackId,
|
||||
clientId = cid,
|
||||
source = source.takeIf { !it.isNullOrEmpty() },
|
||||
deviceClass = DEVICE_CLASS,
|
||||
),
|
||||
)
|
||||
if (curTrackId == trackId) {
|
||||
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
atIso = startedAt.toString(),
|
||||
durationPlayedMs = durationPlayedMs,
|
||||
source = source.takeIf { !it.isNullOrEmpty() },
|
||||
deviceClass = DEVICE_CLASS,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
|
||||
|
||||
// ── 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) {
|
||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||
// alone: the foreground media service keeps the process — and this
|
||||
|
||||
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
|
||||
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
|
||||
* "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
|
||||
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
||||
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
||||
* uiState.queueIndex stuck on the old track even after Sonos has
|
||||
* advanced. So the tick reads Sonos's reported Track as the source of
|
||||
* truth, rebuilds the index/title fields itself, and force-syncs the
|
||||
* wrapped player as defense in depth.
|
||||
* One position-polling tick. Patches ONLY the smoothly-changing transport
|
||||
* fields (position / duration / play-pause / buffer) via `.copy()`. The
|
||||
* queue index + current track are owned solely by [onEvents], which reads
|
||||
* the authoritative delegate cursor: during a cast,
|
||||
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
|
||||
* the track the renderer is actually playing (matched by identity), so there
|
||||
* 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) {
|
||||
val upnpActive = activeUpnpHolder.active.value != null
|
||||
resolvePendingTransport(controller, upnpActive)
|
||||
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
|
||||
val effectiveIsPlaying =
|
||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||
val effectivePosition = if (upnpActive) {
|
||||
@@ -617,27 +649,29 @@ class PlayerController @Inject constructor(
|
||||
} else {
|
||||
controller.currentPosition
|
||||
}
|
||||
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
||||
val idx = controller.currentMediaItemIndex
|
||||
val current = uiStateInternal.value
|
||||
val newPos = effectivePosition.coerceAtLeast(0)
|
||||
val newDur = effectiveDuration(
|
||||
upnpActive,
|
||||
remoteState.durationMs,
|
||||
controller.duration,
|
||||
desiredIdx = desiredIdx,
|
||||
controllerIdx = controller.currentMediaItemIndex,
|
||||
desiredIdx = idx,
|
||||
controllerIdx = idx,
|
||||
)
|
||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||
// Track adjustments are forward-only AND suppressed while a user
|
||||
// transport press is pending Sonos confirmation. Together those keep
|
||||
// either direction of user input from being undone by a stale poll.
|
||||
val trackChanged = !pendingTransport &&
|
||||
desiredIdx > current.queueIndex &&
|
||||
desiredIdx in queueRefs.indices
|
||||
publishTickIfChanged(
|
||||
current, trackChanged, desiredIdx,
|
||||
effectiveIsPlaying, newPos, newDur, newBuf,
|
||||
)
|
||||
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
|
||||
current.positionMs != newPos ||
|
||||
current.durationMs != newDur ||
|
||||
current.bufferedPositionMs != newBuf
|
||||
if (somethingChanged) {
|
||||
uiStateInternal.value = current.copy(
|
||||
isPlaying = effectiveIsPlaying,
|
||||
positionMs = newPos,
|
||||
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 ──────────────────────────────
|
||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
package com.fabledsword.minstrel.player.ui
|
||||
|
||||
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.PlayerUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
@@ -20,13 +25,29 @@ import javax.inject.Inject
|
||||
* stub-test for ViewModel-level logic when it grows).
|
||||
*/
|
||||
@HiltViewModel
|
||||
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
|
||||
class PlayerViewModel @Inject constructor(
|
||||
private val controller: PlayerController,
|
||||
private val likes: LikesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||
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 pause() = controller.pause()
|
||||
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
||||
@@ -35,4 +56,16 @@ class PlayerViewModel @Inject constructor(
|
||||
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
||||
fun toggleShuffle() = controller.toggleShuffle()
|
||||
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
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -20,22 +28,43 @@ import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
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.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.composables.icons.lucide.ArrowDown
|
||||
import com.composables.icons.lucide.ArrowLeft
|
||||
import com.composables.icons.lucide.GripVertical
|
||||
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.X
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -44,16 +73,35 @@ fun QueueScreen(
|
||||
viewModel: PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
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 = {
|
||||
IconButton(onClick = { navController.popBackStack() }) {
|
||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (state.queue.isNotEmpty()) {
|
||||
IconButton(onClick = viewModel::clearQueue) {
|
||||
Icon(Lucide.Trash2, contentDescription = "Clear queue")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
@@ -67,33 +115,109 @@ fun QueueScreen(
|
||||
QueueList(
|
||||
tracks = state.queue,
|
||||
currentIndex = state.queueIndex,
|
||||
likedTrackIds = likedTrackIds,
|
||||
onJumpTo = viewModel::seekToIndex,
|
||||
onToggleLike = viewModel::toggleLikeTrack,
|
||||
onMove = viewModel::moveInQueue,
|
||||
onRemove = viewModel::removeFromQueue,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
|
||||
@Composable
|
||||
private fun QueueList(
|
||||
tracks: List<TrackRef>,
|
||||
currentIndex: Int,
|
||||
likedTrackIds: Set<String>,
|
||||
onJumpTo: (Int) -> Unit,
|
||||
onToggleLike: (String) -> Unit,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onRemove: (Int) -> Unit,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||
QueueRow(
|
||||
track = track,
|
||||
isCurrent = index == currentIndex,
|
||||
onClick = { onJumpTo(index) },
|
||||
)
|
||||
HorizontalDivider()
|
||||
val listState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||
)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 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
|
||||
// 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
|
||||
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) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||
} else {
|
||||
@@ -102,12 +226,23 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onSizeChanged { rowHeightPx = it.height }
|
||||
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||
.graphicsLayer { translationY = dragOffsetY }
|
||||
.background(highlight)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
DragHandle(
|
||||
index = index,
|
||||
queueSize = queueSize,
|
||||
rowHeightPx = rowHeightPx,
|
||||
onOffsetChange = { dragOffsetY = it },
|
||||
onMove = onMove,
|
||||
)
|
||||
QueueRowThumbnail(track = track)
|
||||
if (isCurrent) {
|
||||
Icon(
|
||||
Lucide.Volume2,
|
||||
@@ -115,26 +250,7 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val subtitle = queueSubtitle(track)
|
||||
if (subtitle.isNotEmpty()) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
||||
if (track.durationSec > 0) {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec),
|
||||
@@ -142,6 +258,95 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
||||
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() }
|
||||
.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 SECONDS_PER_MINUTE = 60
|
||||
private const val MINUTES_PER_HOUR = 60
|
||||
|
||||
+3
-3
@@ -119,9 +119,9 @@ class PlaylistsRepository @Inject constructor(
|
||||
throw e
|
||||
}
|
||||
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
||||
playlistTrackDao.deleteByPlaylist(id)
|
||||
playlistTrackDao.upsertAll(
|
||||
wire.tracks.mapNotNull { row ->
|
||||
playlistTrackDao.replacePlaylistTracks(
|
||||
playlistId = id,
|
||||
rows = wire.tracks.mapNotNull { row ->
|
||||
row.trackId?.let { trackId ->
|
||||
CachedPlaylistTrackEntity(
|
||||
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
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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.draw.alpha
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import coil3.compose.AsyncImage
|
||||
import coil3.compose.AsyncImagePainter
|
||||
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
|
||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||
* is blank/unresolvable, while the image is still loading, and when the
|
||||
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||
* which the "You might like" row hits often).
|
||||
*
|
||||
* 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
|
||||
fun ServerImage(
|
||||
@@ -40,6 +55,23 @@ fun ServerImage(
|
||||
var state by remember(resolved) {
|
||||
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) {
|
||||
AsyncImage(
|
||||
model = resolved,
|
||||
@@ -48,10 +80,10 @@ fun ServerImage(
|
||||
contentScale = contentScale,
|
||||
onState = { state = it },
|
||||
)
|
||||
if (state is AsyncImagePainter.State.Loading ||
|
||||
state is AsyncImagePainter.State.Error
|
||||
) {
|
||||
fallback()
|
||||
if (fallbackAlpha > 0f) {
|
||||
Box(Modifier.alpha(fallbackAlpha), contentAlignment = Alignment.Center) {
|
||||
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-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-flutter:3.44` — Flutter client tests + debug/release APK builds (`.gitea/workflows/flutter.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-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
|
||||
|
||||
@@ -25,13 +31,20 @@ git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
||||
- **docker buildx** — release container build + push in `release.yml`.
|
||||
- **curl** — release-asset polling / upload in `release.yml`.
|
||||
|
||||
### From `ci-flutter:3.44`
|
||||
- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release).
|
||||
- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen).
|
||||
- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing.
|
||||
- **Java 25** — Gradle / Android build.
|
||||
### From `ci-android:36`
|
||||
- **JDK 25** — Gradle launcher + Android build. Requires Gradle 9.1.0+ in
|
||||
`android/gradle/wrapper`; older Gradle rejects JDK 25 with an opaque `"25.0.3"`
|
||||
error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED`
|
||||
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).
|
||||
- **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
|
||||
|
||||
@@ -39,9 +52,69 @@ None.
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"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/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
@@ -206,6 +207,12 @@ func run() error {
|
||||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||||
go similarityWorker.Run(ctx)
|
||||
|
||||
// Start the household co-play worker (#1533). Recomputes artist–artist
|
||||
// co-occurrence edges (source='user_cooccurrence') from play_events every
|
||||
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
|
||||
// SQL, no external calls; empty on single-user servers.
|
||||
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
|
||||
|
||||
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
||||
// tag providers with tag_provider_settings, bumps the sources version if
|
||||
// the provider set changed (re-opening settled rows), then drains tracks
|
||||
|
||||
@@ -20,26 +20,28 @@ import (
|
||||
// weightsResp is one weight profile on the wire, keyed by the same
|
||||
// snake_case field names the PATCH body accepts.
|
||||
type weightsResp struct {
|
||||
BaseWeight float64 `json:"base_weight"`
|
||||
LikeBoost float64 `json:"like_boost"`
|
||||
RecencyWeight float64 `json:"recency_weight"`
|
||||
SkipPenalty float64 `json:"skip_penalty"`
|
||||
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||
ContextWeight float64 `json:"context_weight"`
|
||||
SimilarityWeight float64 `json:"similarity_weight"`
|
||||
TasteWeight float64 `json:"taste_weight"`
|
||||
BaseWeight float64 `json:"base_weight"`
|
||||
LikeBoost float64 `json:"like_boost"`
|
||||
RecencyWeight float64 `json:"recency_weight"`
|
||||
SkipPenalty float64 `json:"skip_penalty"`
|
||||
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||
ContextWeight float64 `json:"context_weight"`
|
||||
SimilarityWeight float64 `json:"similarity_weight"`
|
||||
TasteWeight float64 `json:"taste_weight"`
|
||||
ContextTimeWeight float64 `json:"context_time_weight"`
|
||||
}
|
||||
|
||||
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
||||
return weightsResp{
|
||||
BaseWeight: w.BaseWeight,
|
||||
LikeBoost: w.LikeBoost,
|
||||
RecencyWeight: w.RecencyWeight,
|
||||
SkipPenalty: w.SkipPenalty,
|
||||
JitterMagnitude: w.JitterMagnitude,
|
||||
ContextWeight: w.ContextWeight,
|
||||
SimilarityWeight: w.SimilarityWeight,
|
||||
TasteWeight: w.TasteWeight,
|
||||
BaseWeight: w.BaseWeight,
|
||||
LikeBoost: w.LikeBoost,
|
||||
RecencyWeight: w.RecencyWeight,
|
||||
SkipPenalty: w.SkipPenalty,
|
||||
JitterMagnitude: w.JitterMagnitude,
|
||||
ContextWeight: w.ContextWeight,
|
||||
SimilarityWeight: w.SimilarityWeight,
|
||||
TasteWeight: w.TasteWeight,
|
||||
ContextTimeWeight: w.ContextTimeWeight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +50,9 @@ type tasteTuningResp struct {
|
||||
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||
EngagementFull float64 `json:"engagement_full"`
|
||||
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
||||
EraScale float64 `json:"era_scale"`
|
||||
MoodScale float64 `json:"mood_scale"`
|
||||
}
|
||||
|
||||
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||
@@ -56,6 +61,22 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||
EngagementHardSkip: t.EngagementHardSkip,
|
||||
EngagementNeutral: t.EngagementNeutral,
|
||||
EngagementFull: t.EngagementFull,
|
||||
EnrichedTagScale: t.EnrichedTagScale,
|
||||
EraScale: t.EraScale,
|
||||
MoodScale: t.MoodScale,
|
||||
}
|
||||
}
|
||||
|
||||
// discoverTuningResp is the Discover scope on the wire (#2377).
|
||||
type discoverTuningResp struct {
|
||||
TagOverlapWeight float64 `json:"tag_overlap_weight"`
|
||||
SnoozeDays float64 `json:"snooze_days"`
|
||||
}
|
||||
|
||||
func discoverRespFrom(d recsettings.DiscoverTuning) discoverTuningResp {
|
||||
return discoverTuningResp{
|
||||
TagOverlapWeight: d.TagOverlapWeight,
|
||||
SnoozeDays: d.SnoozeDays,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +86,11 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||
type tuningSnapshot struct {
|
||||
Profiles map[string]weightsResp `json:"profiles"`
|
||||
Taste tasteTuningResp `json:"taste"`
|
||||
Discover discoverTuningResp `json:"discover"`
|
||||
Shipped struct {
|
||||
Profiles map[string]weightsResp `json:"profiles"`
|
||||
Taste tasteTuningResp `json:"taste"`
|
||||
Discover discoverTuningResp `json:"discover"`
|
||||
} `json:"shipped"`
|
||||
}
|
||||
|
||||
@@ -78,11 +101,13 @@ func (h *handlers) tuningSnapshot() tuningSnapshot {
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||
}
|
||||
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||
out.Discover = discoverRespFrom(h.recSettings.Discover())
|
||||
out.Shipped.Profiles = map[string]weightsResp{
|
||||
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||
}
|
||||
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -112,9 +137,12 @@ func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *htt
|
||||
}
|
||||
|
||||
var err error
|
||||
if scope == recsettings.ScopeTaste {
|
||||
switch scope {
|
||||
case recsettings.ScopeTaste:
|
||||
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)
|
||||
}
|
||||
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("/radio", h.handleRadio)
|
||||
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/index", h.handleGetHomeIndex)
|
||||
authed.Post("/events", h.handleEvents)
|
||||
|
||||
+33
-2
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -24,6 +25,33 @@ type eventRequest struct {
|
||||
// / "discover" feed the system-playlist rotation dedup (#415);
|
||||
// absent / "" for library, user-playlist, radio, Subsonic.
|
||||
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 {
|
||||
@@ -114,7 +142,9 @@ func (h *handlers) handleEventPlayStarted(
|
||||
if req.Source != nil {
|
||||
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 {
|
||||
h.logger.Error("api: events: play_started", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||
@@ -159,7 +189,8 @@ func (h *handlers) handleEventPlayOffline(
|
||||
source = *req.Source
|
||||
}
|
||||
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 {
|
||||
h.logger.Error("api: events: play_offline", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
@@ -81,6 +82,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
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"))
|
||||
limits := recommendation.DefaultCandidateSourceLimits()
|
||||
@@ -150,6 +154,24 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
|
||||
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
|
||||
// `exclude` query string, silently dropping malformed entries. Returns
|
||||
// nil for empty or all-malformed input.
|
||||
|
||||
+159
-1
@@ -2,13 +2,19 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"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/recommendation"
|
||||
)
|
||||
@@ -19,6 +25,12 @@ type suggestionView struct {
|
||||
Name string `json:"name"`
|
||||
Score float64 `json:"score"`
|
||||
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
|
||||
// artists have no local art row). Omitted when Lidarr is disabled
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
h.logger.Error("api: list suggestions", "err", 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{
|
||||
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
|
||||
MatchedTags: s.MatchedTags,
|
||||
})
|
||||
}
|
||||
h.resolveSuggestionArt(r.Context(), 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
|
||||
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
|
||||
// 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
|
||||
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
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
@@ -80,12 +80,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
&i.DeviceClass,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -106,6 +107,7 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
&i.DeviceClass,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -144,19 +146,22 @@ func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPi
|
||||
|
||||
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
||||
INSERT INTO play_events (
|
||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::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
|
||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
|
||||
device_class
|
||||
) 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 {
|
||||
UserID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
SessionID pgtype.UUID
|
||||
StartedAt pgtype.Timestamptz
|
||||
ClientID *string
|
||||
Source *string
|
||||
PickKind *string
|
||||
UserID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
SessionID pgtype.UUID
|
||||
StartedAt pgtype.Timestamptz
|
||||
ClientID *string
|
||||
Source *string
|
||||
PickKind *string
|
||||
DeviceClass *string
|
||||
}
|
||||
|
||||
// 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.Source,
|
||||
arg.PickKind,
|
||||
arg.DeviceClass,
|
||||
)
|
||||
var i PlayEvent
|
||||
err := row.Scan(
|
||||
@@ -188,6 +194,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
&i.DeviceClass,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -333,7 +340,7 @@ SET ended_at = $2,
|
||||
completion_ratio = $4,
|
||||
was_skipped = $5
|
||||
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 {
|
||||
@@ -371,6 +378,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
&i.DeviceClass,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
+57
-10
@@ -240,6 +240,19 @@ type AuditLog struct {
|
||||
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 {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
@@ -277,6 +290,13 @@ type DiagnosticEvent struct {
|
||||
ReceivedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type DiscoverTuning struct {
|
||||
Singleton bool
|
||||
TagOverlapWeight float64
|
||||
SnoozeDays float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type GeneralLike struct {
|
||||
UserID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
@@ -384,6 +404,7 @@ type PlayEvent struct {
|
||||
ScrobbledAt pgtype.Timestamptz
|
||||
Source *string
|
||||
PickKind *string
|
||||
DeviceClass *string
|
||||
}
|
||||
|
||||
type PlaySession struct {
|
||||
@@ -446,16 +467,17 @@ type RecommendationTuningAudit struct {
|
||||
}
|
||||
|
||||
type RecommendationWeightProfile struct {
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
ContextTimeWeight float64
|
||||
}
|
||||
|
||||
type RegistrationSetting struct {
|
||||
@@ -516,6 +538,14 @@ type SmtpConfig struct {
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type SuggestionSnooze struct {
|
||||
UserID pgtype.UUID
|
||||
CandidateMbid string
|
||||
CandidateName string
|
||||
SnoozedUntil pgtype.Timestamptz
|
||||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type SystemPlaylistRotationState struct {
|
||||
UserID pgtype.UUID
|
||||
PlaylistKind string
|
||||
@@ -554,6 +584,20 @@ type TasteProfileArtist struct {
|
||||
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 {
|
||||
UserID pgtype.UUID
|
||||
Tag string
|
||||
@@ -568,6 +612,9 @@ type TasteTuning struct {
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
|
||||
@@ -11,6 +11,110 @@ import (
|
||||
"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
|
||||
WITH user_plays AS (
|
||||
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,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date
|
||||
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 LATERAL (
|
||||
SELECT
|
||||
@@ -620,6 +726,7 @@ type LoadRadioCandidatesRow struct {
|
||||
LastPlayedAt pgtype.Timestamptz
|
||||
PlayCount int64
|
||||
SkipCount int64
|
||||
ReleaseDate pgtype.Date
|
||||
}
|
||||
|
||||
// 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.PlayCount,
|
||||
&i.SkipCount,
|
||||
&i.ReleaseDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -754,6 +862,22 @@ taste_overlap AS (
|
||||
ORDER BY tpa.weight DESC, t.id
|
||||
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 (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -765,6 +889,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
UNION SELECT track_id FROM coplay_artists
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -775,6 +900,7 @@ SELECT
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date,
|
||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||
FROM (
|
||||
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 likes_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
|
||||
) u
|
||||
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 LATERAL (
|
||||
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,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
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 {
|
||||
@@ -810,6 +939,7 @@ type LoadRadioCandidatesV2Params struct {
|
||||
Limit_4 int32
|
||||
Limit_5 int32
|
||||
Limit_6 int32
|
||||
Limit_7 int32
|
||||
}
|
||||
|
||||
type LoadRadioCandidatesV2Row struct {
|
||||
@@ -818,6 +948,7 @@ type LoadRadioCandidatesV2Row struct {
|
||||
LastPlayedAt pgtype.Timestamptz
|
||||
PlayCount int64
|
||||
SkipCount int64
|
||||
ReleaseDate pgtype.Date
|
||||
SimilarityScore interface{}
|
||||
}
|
||||
|
||||
@@ -829,7 +960,9 @@ type LoadRadioCandidatesV2Row struct {
|
||||
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||||
// enter the pool even when the similarity/random arms miss them; scored
|
||||
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||
// 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.
|
||||
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_5,
|
||||
arg.Limit_6,
|
||||
arg.Limit_7,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -874,6 +1008,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
&i.LastPlayedAt,
|
||||
&i.PlayCount,
|
||||
&i.SkipCount,
|
||||
&i.ReleaseDate,
|
||||
&i.SimilarityScore,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -887,20 +1022,50 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
}
|
||||
|
||||
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,
|
||||
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)
|
||||
AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COUNT(pe.id)::bigint AS play_count
|
||||
AS raw_signal
|
||||
FROM artists a
|
||||
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 play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
||||
LEFT JOIN play_events pe
|
||||
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
|
||||
),
|
||||
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 (
|
||||
SELECT u.candidate_mbid,
|
||||
u.candidate_name,
|
||||
@@ -917,6 +1082,18 @@ contributions AS (
|
||||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||||
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,
|
||||
candidate_name,
|
||||
@@ -947,13 +1124,24 @@ type SuggestArtistsForUserRow struct {
|
||||
TopPlayCounts []int64
|
||||
}
|
||||
|
||||
// M5c: per-user artist suggestions ranked by signal x similarity. The
|
||||
// seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
||||
// (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
||||
// artist_similarity_unmatched and filters out candidates already in
|
||||
// library or already in a non-terminal lidarr_request. The outer SELECT
|
||||
// aggregates per candidate, returning the top-3 contributing seeds for
|
||||
// attribution. $1=user_id, $2=half_life_days, $3=limit.
|
||||
// Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||
// through artist_similarity_unmatched (out-of-library candidates only).
|
||||
//
|
||||
// Seeds are TIERED (rule #131) so the surface never empties:
|
||||
//
|
||||
// tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||
// 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) {
|
||||
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -9,8 +9,24 @@ import (
|
||||
"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
|
||||
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) {
|
||||
@@ -23,6 +39,9 @@ func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||
&i.EngagementNeutral,
|
||||
&i.EngagementFull,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrichedTagScale,
|
||||
&i.EraScale,
|
||||
&i.MoodScale,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -80,7 +99,7 @@ func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]Recommend
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -103,6 +122,7 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
|
||||
&i.SimilarityWeight,
|
||||
&i.TasteWeight,
|
||||
&i.UpdatedAt,
|
||||
&i.ContextTimeWeight,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,15 +134,44 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
|
||||
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
|
||||
UPDATE taste_tuning
|
||||
SET half_life_days = $1,
|
||||
engagement_hard_skip = $2,
|
||||
engagement_neutral = $3,
|
||||
engagement_full = $4,
|
||||
enriched_tag_scale = $5,
|
||||
era_scale = $6,
|
||||
mood_scale = $7,
|
||||
updated_at = now()
|
||||
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 {
|
||||
@@ -130,6 +179,9 @@ type UpdateTasteTuningParams struct {
|
||||
EngagementHardSkip float64
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
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.EngagementNeutral,
|
||||
arg.EngagementFull,
|
||||
arg.EnrichedTagScale,
|
||||
arg.EraScale,
|
||||
arg.MoodScale,
|
||||
)
|
||||
var i TasteTuning
|
||||
err := row.Scan(
|
||||
@@ -147,35 +202,40 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
||||
&i.EngagementNeutral,
|
||||
&i.EngagementFull,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrichedTagScale,
|
||||
&i.EraScale,
|
||||
&i.MoodScale,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateWeightProfile = `-- name: UpdateWeightProfile :one
|
||||
UPDATE recommendation_weight_profiles
|
||||
SET base_weight = $2,
|
||||
like_boost = $3,
|
||||
recency_weight = $4,
|
||||
skip_penalty = $5,
|
||||
jitter_magnitude = $6,
|
||||
context_weight = $7,
|
||||
similarity_weight = $8,
|
||||
taste_weight = $9,
|
||||
updated_at = now()
|
||||
SET base_weight = $2,
|
||||
like_boost = $3,
|
||||
recency_weight = $4,
|
||||
skip_penalty = $5,
|
||||
jitter_magnitude = $6,
|
||||
context_weight = $7,
|
||||
similarity_weight = $8,
|
||||
taste_weight = $9,
|
||||
context_time_weight = $10,
|
||||
updated_at = now()
|
||||
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 {
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
ContextTimeWeight float64
|
||||
}
|
||||
|
||||
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.SimilarityWeight,
|
||||
arg.TasteWeight,
|
||||
arg.ContextTimeWeight,
|
||||
)
|
||||
var i RecommendationWeightProfile
|
||||
err := row.Scan(
|
||||
@@ -202,15 +263,35 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
||||
&i.SimilarityWeight,
|
||||
&i.TasteWeight,
|
||||
&i.UpdatedAt,
|
||||
&i.ContextTimeWeight,
|
||||
)
|
||||
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
|
||||
INSERT INTO taste_tuning (
|
||||
singleton, half_life_days, engagement_hard_skip,
|
||||
engagement_neutral, engagement_full
|
||||
) VALUES (true, $1, $2, $3, $4)
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||
mood_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (singleton) DO NOTHING
|
||||
`
|
||||
|
||||
@@ -219,6 +300,9 @@ type UpsertTasteTuningDefaultsParams struct {
|
||||
EngagementHardSkip float64
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
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.EngagementNeutral,
|
||||
arg.EngagementFull,
|
||||
arg.EnrichedTagScale,
|
||||
arg.EraScale,
|
||||
arg.MoodScale,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -235,21 +322,23 @@ const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
|
||||
|
||||
INSERT INTO recommendation_weight_profiles (
|
||||
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
jitter_magnitude, context_weight, similarity_weight, taste_weight,
|
||||
context_time_weight
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (profile) DO NOTHING
|
||||
`
|
||||
|
||||
type UpsertWeightProfileDefaultsParams struct {
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
ContextTimeWeight float64
|
||||
}
|
||||
|
||||
// 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.SimilarityWeight,
|
||||
arg.TasteWeight,
|
||||
arg.ContextTimeWeight,
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
DELETE FROM taste_profile_tags WHERE user_id = $1
|
||||
`
|
||||
@@ -45,6 +63,38 @@ func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteP
|
||||
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
|
||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -87,21 +137,23 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
|
||||
}
|
||||
|
||||
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
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE gl.user_id = $1
|
||||
`
|
||||
|
||||
type ListLikedTrackTasteInputsForUserRow struct {
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
ReleaseDate pgtype.Date
|
||||
}
|
||||
|
||||
// (track_id, artist_id, genre) for each track the user has explicitly
|
||||
// liked. Feeds the track-like bonus into the liked track's artist and
|
||||
// tags; track_id keys the enriched track_tags lookup (#1490).
|
||||
// (track_id, artist_id, genre, release_date) for each track the user has
|
||||
// explicitly liked. Feeds the track-like bonus into the liked track's artist,
|
||||
// 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) {
|
||||
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
||||
if err != nil {
|
||||
@@ -111,7 +163,12 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
||||
var items []ListLikedTrackTasteInputsForUserRow
|
||||
for rows.Next() {
|
||||
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
|
||||
}
|
||||
items = append(items, i)
|
||||
@@ -128,6 +185,7 @@ SELECT
|
||||
t.id AS track_id,
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
a.release_date,
|
||||
LEAST(GREATEST(
|
||||
COALESCE(pe.completion_ratio,
|
||||
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
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
AND NOT EXISTS (
|
||||
@@ -149,11 +208,12 @@ type ListPlayEngagementInputsForUserParams struct {
|
||||
}
|
||||
|
||||
type ListPlayEngagementInputsForUserRow struct {
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
Completion float64
|
||||
AgeDays float64
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
ReleaseDate pgtype.Date
|
||||
Completion float64
|
||||
AgeDays float64
|
||||
}
|
||||
|
||||
// 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
|
||||
// completion ratio (precomputed column when present, else duration_played /
|
||||
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
// is split into tags in Go. Quarantined tracks are excluded.
|
||||
// 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) {
|
||||
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
@@ -176,6 +237,7 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
|
||||
&i.TrackID,
|
||||
&i.ArtistID,
|
||||
&i.Genre,
|
||||
&i.ReleaseDate,
|
||||
&i.Completion,
|
||||
&i.AgeDays,
|
||||
); err != nil {
|
||||
@@ -228,6 +290,84 @@ func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTa
|
||||
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
|
||||
SELECT tag, weight
|
||||
FROM taste_profile_tags
|
||||
|
||||
@@ -110,9 +110,43 @@ func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayed
|
||||
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
|
||||
|
||||
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
|
||||
JOIN artists a ON a.id = t.artist_id
|
||||
WHERE t.tag_source IS NULL
|
||||
@@ -131,6 +165,7 @@ type ListTracksMissingTagsRow struct {
|
||||
Mbid *string
|
||||
Title string
|
||||
ArtistName string
|
||||
ArtistMbid *string
|
||||
}
|
||||
|
||||
// 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)
|
||||
// or previously settled 'none' under an older provider version. Returns
|
||||
// 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.
|
||||
func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) {
|
||||
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.Title,
|
||||
&i.ArtistName,
|
||||
&i.ArtistMbid,
|
||||
); err != nil {
|
||||
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
|
||||
-- variant at ingestion time (#1249, generalized in #1270).
|
||||
INSERT INTO play_events (
|
||||
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)
|
||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
|
||||
device_class
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text,
|
||||
sqlc.narg(device_class)::text)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSystemPickKindForTrack :one
|
||||
|
||||
@@ -10,8 +10,10 @@ SELECT
|
||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date
|
||||
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 LATERAL (
|
||||
SELECT
|
||||
@@ -40,7 +42,9 @@ WHERE t.id <> $2
|
||||
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||
-- positively-weighted taste-profile artists, so taste-relevant tracks
|
||||
-- enter the pool even when the similarity/random arms miss them; scored
|
||||
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||
-- 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.
|
||||
|
||||
WITH
|
||||
@@ -124,6 +128,22 @@ taste_overlap AS (
|
||||
ORDER BY tpa.weight DESC, t.id
|
||||
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 (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -135,6 +155,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
UNION SELECT track_id FROM coplay_artists
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -145,6 +166,7 @@ SELECT
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date,
|
||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||
FROM (
|
||||
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 likes_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
|
||||
) u
|
||||
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 LATERAL (
|
||||
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,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
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
|
||||
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
||||
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
||||
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
||||
-- artist_similarity_unmatched and filters out candidates already in
|
||||
-- library or already in a non-terminal lidarr_request. The outer SELECT
|
||||
-- aggregates per candidate, returning the top-3 contributing seeds for
|
||||
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
|
||||
WITH seeds AS (
|
||||
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||||
--
|
||||
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||||
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||
-- 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.
|
||||
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,
|
||||
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)
|
||||
AS signal,
|
||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||
COUNT(pe.id)::bigint AS play_count
|
||||
AS raw_signal
|
||||
FROM artists a
|
||||
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 play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
||||
LEFT JOIN play_events pe
|
||||
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
|
||||
),
|
||||
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 (
|
||||
SELECT u.candidate_mbid,
|
||||
u.candidate_name,
|
||||
@@ -206,6 +335,18 @@ contributions AS (
|
||||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||||
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,
|
||||
candidate_name,
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
-- doesn't exist yet. Never overwrites operator-tuned values.
|
||||
INSERT INTO recommendation_weight_profiles (
|
||||
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
jitter_magnitude, context_weight, similarity_weight, taste_weight,
|
||||
context_time_weight
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (profile) DO NOTHING;
|
||||
|
||||
-- name: ListWeightProfiles :many
|
||||
@@ -15,23 +16,25 @@ SELECT * FROM recommendation_weight_profiles ORDER BY profile;
|
||||
|
||||
-- name: UpdateWeightProfile :one
|
||||
UPDATE recommendation_weight_profiles
|
||||
SET base_weight = $2,
|
||||
like_boost = $3,
|
||||
recency_weight = $4,
|
||||
skip_penalty = $5,
|
||||
jitter_magnitude = $6,
|
||||
context_weight = $7,
|
||||
similarity_weight = $8,
|
||||
taste_weight = $9,
|
||||
updated_at = now()
|
||||
SET base_weight = $2,
|
||||
like_boost = $3,
|
||||
recency_weight = $4,
|
||||
skip_penalty = $5,
|
||||
jitter_magnitude = $6,
|
||||
context_weight = $7,
|
||||
similarity_weight = $8,
|
||||
taste_weight = $9,
|
||||
context_time_weight = $10,
|
||||
updated_at = now()
|
||||
WHERE profile = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpsertTasteTuningDefaults :exec
|
||||
INSERT INTO taste_tuning (
|
||||
singleton, half_life_days, engagement_hard_skip,
|
||||
engagement_neutral, engagement_full
|
||||
) VALUES (true, $1, $2, $3, $4)
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||
mood_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (singleton) DO NOTHING;
|
||||
|
||||
-- name: GetTasteTuning :one
|
||||
@@ -43,10 +46,31 @@ UPDATE taste_tuning
|
||||
engagement_hard_skip = $2,
|
||||
engagement_neutral = $3,
|
||||
engagement_full = $4,
|
||||
enriched_tag_scale = $5,
|
||||
era_scale = $6,
|
||||
mood_scale = $7,
|
||||
updated_at = now()
|
||||
WHERE singleton = true
|
||||
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
|
||||
-- changes is a jsonb array of {field, old, new} objects.
|
||||
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
|
||||
-- completion ratio (precomputed column when present, else duration_played /
|
||||
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
-- is split into tags in Go. Quarantined tracks are excluded.
|
||||
-- is split into tags in Go; release_date derives the decade for the era
|
||||
-- facet (#1530). Quarantined tracks are excluded.
|
||||
SELECT
|
||||
t.id AS track_id,
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
a.release_date,
|
||||
LEAST(GREATEST(
|
||||
COALESCE(pe.completion_ratio,
|
||||
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
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
AND NOT EXISTS (
|
||||
@@ -26,12 +29,13 @@ WHERE pe.user_id = $1
|
||||
);
|
||||
|
||||
-- name: ListLikedTrackTasteInputsForUser :many
|
||||
-- (track_id, artist_id, genre) for each track the user has explicitly
|
||||
-- liked. Feeds the track-like bonus into the liked track's artist and
|
||||
-- tags; track_id keys the enriched track_tags lookup (#1490).
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre
|
||||
-- (track_id, artist_id, genre, release_date) for each track the user has
|
||||
-- explicitly liked. Feeds the track-like bonus into the liked track's artist,
|
||||
-- tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE gl.user_id = $1;
|
||||
|
||||
-- name: ListLikedArtistIDsForUser :many
|
||||
@@ -66,3 +70,33 @@ FROM taste_profile_tags
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
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)
|
||||
-- or previously settled 'none' under an older provider version. Returns
|
||||
-- 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.
|
||||
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
|
||||
JOIN artists a ON a.id = t.artist_id
|
||||
WHERE t.tag_source IS NULL
|
||||
@@ -55,3 +56,11 @@ SELECT tt.track_id, tt.tag, tt.weight
|
||||
FROM track_tags tt
|
||||
JOIN general_likes gl ON gl.track_id = tt.track_id
|
||||
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",
|
||||
"lidarr_quarantine_actions",
|
||||
"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",
|
||||
"playlists",
|
||||
"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.
|
||||
"recommendation_weight_profiles",
|
||||
"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",
|
||||
"tracks",
|
||||
"albums",
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
// - GcResetStuckSystemPlaylistRuns (#574)
|
||||
// - GcDeleteExpiredPasswordResets (#575)
|
||||
// - 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
|
||||
|
||||
import (
|
||||
@@ -84,6 +87,14 @@ func (w *Worker) tickOnce(ctx context.Context) {
|
||||
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
||||
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
at time.Time,
|
||||
) (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
|
||||
@@ -136,7 +145,7 @@ func lookupSystemPickKind(
|
||||
func (w *Writer) RecordPlayStartedWithSource(
|
||||
ctx context.Context,
|
||||
userID, trackID pgtype.UUID,
|
||||
clientID, source string,
|
||||
clientID, source, deviceClass string,
|
||||
at time.Time,
|
||||
) (StartedResult, error) {
|
||||
var out StartedResult
|
||||
@@ -165,13 +174,14 @@ func (w *Writer) RecordPlayStartedWithSource(
|
||||
}
|
||||
}
|
||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
DeviceClass: strPtrOrNil(deviceClass),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -389,7 +399,7 @@ func (w *Writer) RecordSyntheticCompletedPlay(
|
||||
func (w *Writer) RecordOfflinePlay(
|
||||
ctx context.Context,
|
||||
userID, trackID pgtype.UUID,
|
||||
clientID, source string,
|
||||
clientID, source, deviceClass string,
|
||||
at time.Time,
|
||||
durationPlayedMs int32,
|
||||
) error {
|
||||
@@ -441,13 +451,14 @@ func (w *Writer) RecordOfflinePlay(
|
||||
}
|
||||
}
|
||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
DeviceClass: strPtrOrNil(deviceClass),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -226,10 +226,10 @@ func TestRecordOfflinePlay_DedupsByUserTrackStartedAt(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
ctx := context.Background()
|
||||
at := time.Now().UTC()
|
||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
|
||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", "", at, 120_000); err != nil {
|
||||
t.Fatalf("first offline: %v", err)
|
||||
}
|
||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
|
||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", "", at, 120_000); err != nil {
|
||||
t.Fatalf("replay offline: %v", err)
|
||||
}
|
||||
var count int
|
||||
@@ -444,7 +444,7 @@ func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
now := time.Now().UTC()
|
||||
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 {
|
||||
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).
|
||||
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 {
|
||||
t.Fatalf("RecordPlayStartedWithSource 2: %v", err)
|
||||
}
|
||||
@@ -511,7 +511,7 @@ func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
seedSystemSnapshot(t, f, "for_you", "fresh")
|
||||
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 {
|
||||
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.
|
||||
f := newFixture(t, 200_000)
|
||||
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 {
|
||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||
@@ -552,7 +552,7 @@ func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||
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 {
|
||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||
@@ -572,7 +572,7 @@ func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
seedSystemSnapshot(t, f, "discover", "dormant")
|
||||
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 {
|
||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||
@@ -593,7 +593,7 @@ func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
|
||||
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||
at := time.Now().UTC().Add(-time.Hour)
|
||||
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 {
|
||||
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
|
||||
// while passive avoidance (negative) gently demotes.
|
||||
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()
|
||||
)
|
||||
@@ -543,6 +546,9 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
||||
// deep slice of the user's top taste-profile artists into the pool
|
||||
// (#796 phase 2b). Empty for cold-start users (no profile yet).
|
||||
TasteOverlap: 80,
|
||||
// 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"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||
)
|
||||
|
||||
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
||||
@@ -41,6 +42,11 @@ func LoadCandidates(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
@@ -57,7 +63,11 @@ func LoadCandidates(
|
||||
PlayCount: int(r.PlayCount),
|
||||
SkipCount: int(r.SkipCount),
|
||||
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
|
||||
// users have an empty profile, so it contributes nothing anyway).
|
||||
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.
|
||||
@@ -87,6 +101,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
LikesOverlap: 20,
|
||||
RandomFill: 30,
|
||||
TasteOverlap: 20,
|
||||
UserCoplay: 20,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +134,7 @@ func LoadCandidatesFromSimilarity(
|
||||
Limit_4: int32(limits.LikesOverlap),
|
||||
Limit_5: int32(limits.RandomFill),
|
||||
Limit_6: int32(limits.TasteOverlap),
|
||||
Limit_7: int32(limits.UserCoplay),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -134,6 +150,20 @@ func LoadCandidatesFromSimilarity(
|
||||
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))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
@@ -158,13 +188,42 @@ func LoadCandidatesFromSimilarity(
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
// profile signal (cold start / artist+tags absent from the profile).
|
||||
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
|
||||
// config.RecommendationConfig and are propagated here per request.
|
||||
type ScoringWeights struct {
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
ContextTimeWeight float64
|
||||
}
|
||||
|
||||
// Score computes the weighted-shuffle score per spec §6:
|
||||
@@ -48,6 +55,7 @@ type ScoringWeights struct {
|
||||
// + contextual_match_score * ContextWeight
|
||||
// + similarity_score * SimilarityWeight
|
||||
// + taste_match_score * TasteWeight
|
||||
// + context_affinity_score * ContextTimeWeight
|
||||
// + small_random_jitter
|
||||
//
|
||||
// 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.SimilarityScore * w.SimilarityWeight
|
||||
s += in.TasteMatchScore * w.TasteWeight
|
||||
s += in.ContextAffinityScore * w.ContextTimeWeight
|
||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ type SessionVector struct {
|
||||
Artists []string `json:"artists"`
|
||||
Tags map[string]int `json:"tags"`
|
||||
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 {
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
// suggestions.go is the M5c per-user artist-suggestion service. Reads
|
||||
// the user's likes + plays, projects them through artist_similarity_unmatched
|
||||
// via a single CTE, returns top-N candidates with top-3 attribution seeds
|
||||
// resolved to artist names.
|
||||
// suggestions.go is the per-user artist-suggestion service behind the
|
||||
// Discover request surface. Seeds from the taste profile (falling back to
|
||||
// likes + completed plays for a user who has none yet), projects those seeds
|
||||
// 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
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -20,6 +31,15 @@ type ArtistSuggestion struct {
|
||||
Name string
|
||||
Score float64
|
||||
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.
|
||||
@@ -32,9 +52,16 @@ type SeedContribution struct {
|
||||
}
|
||||
|
||||
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
||||
// capped at 50 (default 12 when out of range); halfLifeDays is the
|
||||
// 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) {
|
||||
// capped at 50 (default 12 when out of range).
|
||||
//
|
||||
// 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 {
|
||||
limit = 12
|
||||
}
|
||||
@@ -42,10 +69,14 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||
halfLifeDays = 30
|
||||
}
|
||||
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{
|
||||
UserID: userID,
|
||||
Column2: halfLifeDays,
|
||||
Limit: int32(limit),
|
||||
Limit: int32(poolSizeFor(limit)),
|
||||
})
|
||||
if err != nil {
|
||||
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,
|
||||
})
|
||||
}
|
||||
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, 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 {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func TestSuggestArtists_Top12Cap(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func TestSuggestArtists_AttributionTopThree(t *testing.T) {
|
||||
likeArtist(t, pool, user.ID, seeds[i].ID)
|
||||
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 {
|
||||
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, 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 {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) {
|
||||
seedArtist(t, pool, "InLib", inLibMBID)
|
||||
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 {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
@@ -279,7 +279,7 @@ func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("SuggestArtists: %v", err)
|
||||
}
|
||||
@@ -322,7 +322,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
|
||||
func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
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 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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