Compare commits
81
Commits
@@ -80,10 +80,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload debug APK
|
- name: Upload debug APK
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
# Gitea Actions runs in GHES-emulation mode; @actions/artifact v2+
|
# Mirrored action, never actions/upload-artifact. @v4+ throws
|
||||||
# (i.e. upload-artifact@v4+) errors with "GHESNotSupportedError".
|
# GHESNotSupportedError client-side on the hostname (no server setting
|
||||||
# Pin to @v3 until act_runner or the artifact backend catches up.
|
# reaches that check), and @v3 is worse — it reports success while Gitea
|
||||||
uses: actions/upload-artifact@v3
|
# serves artifacts back only through the v4 API, so the upload is stored
|
||||||
|
# and invisible to every retrieval path. @v3 is what left 72 unreachable
|
||||||
|
# artifacts on this repo. Pinned by SHA because the mirror auto-syncs;
|
||||||
|
# full URL because DEFAULT_ACTIONS_URL sends bare owner/repo to github.com.
|
||||||
|
# See Scribe issues 2255 / 2270.
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
with:
|
with:
|
||||||
name: minstrel-android-debug-${{ github.sha }}
|
name: minstrel-android-debug-${{ github.sha }}
|
||||||
path: android/app/build/outputs/apk/debug/app-debug.apk
|
path: android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
|
if-no-files-found: error
|
||||||
|
|||||||
@@ -131,12 +131,19 @@ jobs:
|
|||||||
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
-PMINSTREL_VERSION_CODE=${{ steps.ver.outputs.code }}
|
||||||
|
|
||||||
- name: Upload APK as workflow artifact
|
- name: Upload APK as workflow artifact
|
||||||
# @v3 because Gitea Actions emulates GHES and the v2 artifact
|
# Mirrored action, never actions/upload-artifact — @v4+ refuses on the
|
||||||
# backend used by upload-artifact@v4 errors with GHESNotSupportedError.
|
# hostname, @v3 uploads something Gitea will never serve back. This is
|
||||||
uses: actions/upload-artifact@v3
|
# the producing half of a pair: image-release downloads `minstrel-apk`
|
||||||
|
# below with the matching download-artifact mirror. Both must stay on
|
||||||
|
# the v4 protocol — mixing a v3 upload with a v4 download (or the
|
||||||
|
# reverse) yields an empty listing, not an error. See Scribe 2255 / 2270.
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
with:
|
with:
|
||||||
name: minstrel-apk
|
name: minstrel-apk
|
||||||
path: android/app/build/outputs/apk/release/app-release.apk
|
path: android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
# error, not the default warn: image-release hard-depends on this
|
||||||
|
# artifact existing, so an empty upload must fail here, not there.
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Attach APK to gitea Release
|
- name: Attach APK to gitea Release
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -238,7 +245,20 @@ jobs:
|
|||||||
# Tag pushes only — android-release just produced this. Non-tag
|
# Tag pushes only — android-release just produced this. Non-tag
|
||||||
# builds take the "Bundle latest release APK" path below instead.
|
# builds take the "Bundle latest release APK" path below instead.
|
||||||
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
if: steps.guard.outputs.ready == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||||
uses: actions/download-artifact@v3
|
# Consuming half of the pair — never actions/download-artifact. Same fork,
|
||||||
|
# same reason: upstream's client-side GHES check rejects this hostname
|
||||||
|
# before it connects. bvandeusen/download-artifact mirrors
|
||||||
|
# code.forgejo.org/forgejo/download-artifact.
|
||||||
|
#
|
||||||
|
# SHA below is that fork's `v6` tag. Match on @actions/artifact, NOT on
|
||||||
|
# the action's own version number — the two actions release on unrelated
|
||||||
|
# cadences, and download v5 would pair a ^2.3.2 client with this file's
|
||||||
|
# ^4.0.0 uploader. v6 is the tag whose bundled library major (^4.0.0) is
|
||||||
|
# the same one proven against this instance by the upload side.
|
||||||
|
# Deliberately NOT v7: it moves to node24 and upstream requires runner
|
||||||
|
# >= 2.327.1 for it, which act_runner does not claim to satisfy.
|
||||||
|
# Pinned, not tagged — the mirror auto-syncs every 8h.
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||||
with:
|
with:
|
||||||
name: minstrel-apk
|
name: minstrel-apk
|
||||||
path: client/
|
path: client/
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ on:
|
|||||||
- 'go.mod'
|
- 'go.mod'
|
||||||
- 'go.sum'
|
- 'go.sum'
|
||||||
- 'sqlc.yaml'
|
- 'sqlc.yaml'
|
||||||
|
- 'Makefile'
|
||||||
- 'internal/**'
|
- 'internal/**'
|
||||||
- 'cmd/**'
|
- 'cmd/**'
|
||||||
- '.golangci.yml'
|
- '.golangci.yml'
|
||||||
@@ -53,6 +54,9 @@ jobs:
|
|||||||
go version
|
go version
|
||||||
golangci-lint --version
|
golangci-lint --version
|
||||||
|
|
||||||
|
- name: Generated code matches queries (sqlc)
|
||||||
|
run: make verify-generate
|
||||||
|
|
||||||
- name: go vet
|
- name: go vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,34 @@
|
|||||||
.PHONY: generate test test-short test-integration lint build
|
.PHONY: generate generate-go verify-generate test test-short test-integration lint build
|
||||||
|
|
||||||
|
# renovate: datasource=docker depName=sqlc/sqlc
|
||||||
SQLC_VERSION := 1.31.1
|
SQLC_VERSION := 1.31.1
|
||||||
|
|
||||||
|
# Local codegen. Containerised so a dev needs no sqlc install.
|
||||||
generate:
|
generate:
|
||||||
docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate
|
docker run --rm -v "$(CURDIR):/src" -w /src sqlc/sqlc:$(SQLC_VERSION) generate
|
||||||
|
|
||||||
|
# Same codegen, run as a Go tool instead of a container. This is the CI path:
|
||||||
|
# the ci-go image already has Go, so it avoids docker-in-docker. Pinned to the
|
||||||
|
# SAME version as `generate` above so both routes emit identical output.
|
||||||
|
generate-go:
|
||||||
|
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) generate
|
||||||
|
|
||||||
|
# Fail if the committed generated code no longer matches the .sql sources.
|
||||||
|
#
|
||||||
|
# Nothing verified this before, so internal/db/dbq could silently drift from
|
||||||
|
# internal/db/queries — a hand-edit, a half-applied regen, or a schema change
|
||||||
|
# without a regen would all pass CI while the typed layer lied about the SQL.
|
||||||
|
#
|
||||||
|
# The diff is printed BEFORE the exit-code check on purpose: when this fails,
|
||||||
|
# the log then contains sqlc's exact expected output, which is what you commit.
|
||||||
|
verify-generate: generate-go
|
||||||
|
# -N (intent-to-add) so a BRAND-NEW generated file is visible to `git
|
||||||
|
# diff`, which otherwise ignores untracked paths entirely — a whole
|
||||||
|
# missing *.sql.go would sail through the check below.
|
||||||
|
git add -N -- internal/db/dbq
|
||||||
|
git --no-pager diff -- internal/db/dbq
|
||||||
|
git diff --quiet -- internal/db/dbq
|
||||||
|
|
||||||
test:
|
test:
|
||||||
go test -race ./...
|
go test -race ./...
|
||||||
|
|
||||||
|
|||||||
@@ -210,4 +210,17 @@ dependencies {
|
|||||||
debugImplementation(libs.compose.ui.test.manifest)
|
debugImplementation(libs.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.withType<Test> { useJUnitPlatform() }
|
tasks.withType<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
// Print the assertion message + full stack trace for failures. The
|
||||||
|
// default console output gives only "AssertionError at Foo.kt:12", and
|
||||||
|
// for a failure inside a `runTest { }` lambda even that line collapses
|
||||||
|
// to the test function's own line (the assertion frames live in the
|
||||||
|
// suspend-lambda class, which Gradle filters out) — leaving nothing to
|
||||||
|
// debug from when the HTML report isn't reachable, as in CI.
|
||||||
|
testLogging {
|
||||||
|
events("failed")
|
||||||
|
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
|
||||||
|
showStackTraces = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.work.Configuration
|
|||||||
import coil3.ImageLoader
|
import coil3.ImageLoader
|
||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import coil3.request.crossfade
|
||||||
import com.fabledsword.minstrel.cache.CacheIndexer
|
import com.fabledsword.minstrel.cache.CacheIndexer
|
||||||
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
import com.fabledsword.minstrel.cache.mutations.MutationReplayer
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
@@ -29,6 +30,10 @@ import okhttp3.OkHttpClient
|
|||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
// Cover-art fade-in. Coil skips the transition for memory-cache hits, so
|
||||||
|
// already-loaded art still appears instantly — only a genuine fetch fades.
|
||||||
|
private const val ART_CROSSFADE_MS = 220
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class MinstrelApplication :
|
class MinstrelApplication :
|
||||||
Application(),
|
Application(),
|
||||||
@@ -213,11 +218,18 @@ class MinstrelApplication :
|
|||||||
* OkHttp client as the network fetcher. The `callFactory` lambda
|
* OkHttp client as the network fetcher. The `callFactory` lambda
|
||||||
* is invoked lazily so Hilt has time to inject `okHttpClient`
|
* is invoked lazily so Hilt has time to inject `okHttpClient`
|
||||||
* before Coil makes its first request.
|
* before Coil makes its first request.
|
||||||
|
*
|
||||||
|
* Crossfade is set here rather than per-call so every cover surface
|
||||||
|
* in the app fades its artwork in instead of snapping it. Art
|
||||||
|
* landing a beat after its tile was the most visible pop-in on Home
|
||||||
|
* (issue #2327); `ServerImage` fades its placeholder out over the
|
||||||
|
* same window so the two read as one cross-dissolve.
|
||||||
*/
|
*/
|
||||||
override fun newImageLoader(context: android.content.Context): ImageLoader =
|
override fun newImageLoader(context: android.content.Context): ImageLoader =
|
||||||
ImageLoader.Builder(context)
|
ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
add(OkHttpNetworkFetcherFactory(callFactory = { okHttpClient }))
|
||||||
}
|
}
|
||||||
|
.crossfade(ART_CROSSFADE_MS)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
|
||||||
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-through accessor for the tag-enrichment provider settings (#1521).
|
||||||
|
* No Room caching — admin settings are infrequent point-and-shoot edits;
|
||||||
|
* mutations fire direct REST and the ViewModel reconciles on failure.
|
||||||
|
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AdminTagSourcesRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: AdminTagSourcesApi = retrofit.create()
|
||||||
|
|
||||||
|
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
|
||||||
|
|
||||||
|
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
|
||||||
|
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
|
||||||
|
|
||||||
|
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
|
||||||
|
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
|
||||||
|
|
||||||
|
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
|
||||||
|
id = id,
|
||||||
|
displayName = displayName,
|
||||||
|
requiresApiKey = requiresApiKey,
|
||||||
|
supports = supports,
|
||||||
|
enabled = enabled,
|
||||||
|
apiKeySet = apiKeySet,
|
||||||
|
testable = testable,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
|
||||||
|
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
|
||||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.Invite
|
import com.fabledsword.minstrel.models.Invite
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -25,6 +27,7 @@ data class AdminInvitesUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminInvitesViewModel @Inject constructor(
|
class AdminInvitesViewModel @Inject constructor(
|
||||||
private val repository: AdminInvitesRepository,
|
private val repository: AdminInvitesRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(AdminInvitesUiState())
|
private val internal = MutableStateFlow(AdminInvitesUiState())
|
||||||
@@ -36,6 +39,13 @@ class AdminInvitesViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value.message != null) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
|||||||
@@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.composables.icons.lucide.Inbox
|
import com.composables.icons.lucide.Inbox
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Music
|
||||||
import com.composables.icons.lucide.TriangleAlert
|
import com.composables.icons.lucide.TriangleAlert
|
||||||
import com.composables.icons.lucide.Users
|
import com.composables.icons.lucide.Users
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
|
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.nav.Admin
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
|
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||||
import com.fabledsword.minstrel.nav.AdminUsers
|
import com.fabledsword.minstrel.nav.AdminUsers
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -54,7 +57,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
|
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
|
||||||
|
|
||||||
sealed interface AdminLandingUiState {
|
sealed interface AdminLandingUiState {
|
||||||
data object Loading : AdminLandingUiState
|
data object Loading : AdminLandingUiState
|
||||||
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
private val requestsRepo: AdminRequestsRepository,
|
private val requestsRepo: AdminRequestsRepository,
|
||||||
private val quarantineRepo: AdminQuarantineRepository,
|
private val quarantineRepo: AdminQuarantineRepository,
|
||||||
private val usersRepo: AdminUsersRepository,
|
private val usersRepo: AdminUsersRepository,
|
||||||
|
private val tagSourcesRepo: AdminTagSourcesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||||
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
val req = async { requestsRepo.list().size }
|
val req = async { requestsRepo.list().size }
|
||||||
val qua = async { quarantineRepo.list().size }
|
val qua = async { quarantineRepo.list().size }
|
||||||
val usr = async { usersRepo.list().size }
|
val usr = async { usersRepo.list().size }
|
||||||
AdminCounts(req.await(), qua.await(), usr.await())
|
val tag = async { tagSourcesRepo.list().count { it.enabled } }
|
||||||
|
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
|
||||||
}
|
}
|
||||||
internal.value = AdminLandingUiState.Success(counts)
|
internal.value = AdminLandingUiState.Success(counts)
|
||||||
} catch (
|
} catch (
|
||||||
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
|||||||
onClick = { navController.navigate(AdminUsers) },
|
onClick = { navController.navigate(AdminUsers) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
SectionCard(
|
||||||
|
icon = Lucide.Music,
|
||||||
|
title = "Tag sources",
|
||||||
|
subtitle = "Metadata enrichment providers",
|
||||||
|
count = counts.tagSources,
|
||||||
|
onClick = { navController.navigate(AdminTagSources) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -29,6 +29,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -62,9 +63,10 @@ fun AdminQuarantineScreen(
|
|||||||
body = "When users flag tracks as bad rips, wrong tags, or " +
|
body = "When users flag tracks as bad rips, wrong tags, or " +
|
||||||
"duplicates, their reports get aggregated and surfaced here.",
|
"duplicates, their reports get aggregated and surfaced here.",
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Error -> EmptyState(
|
is AdminQuarantineUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load queue",
|
title = "Couldn't load queue",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Success -> QueueList(
|
is AdminQuarantineUiState.Success -> QueueList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
+10
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -26,6 +28,7 @@ sealed interface AdminQuarantineUiState {
|
|||||||
class AdminQuarantineViewModel @Inject constructor(
|
class AdminQuarantineViewModel @Inject constructor(
|
||||||
private val repository: AdminQuarantineRepository,
|
private val repository: AdminQuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||||
@@ -38,6 +41,13 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminQuarantineUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -61,9 +62,10 @@ fun AdminRequestsScreen(
|
|||||||
body = "When users ask Lidarr for new music, their pending " +
|
body = "When users ask Lidarr for new music, their pending " +
|
||||||
"requests show up here for approval.",
|
"requests show up here for approval.",
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Error -> EmptyState(
|
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Success -> RequestList(
|
is AdminRequestsUiState.Success -> RequestList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -35,6 +37,7 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
private val repository: AdminRequestsRepository,
|
private val repository: AdminRequestsRepository,
|
||||||
private val usersRepository: AdminUsersRepository,
|
private val usersRepository: AdminUsersRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
||||||
@@ -47,6 +50,13 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminRequestsUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.AdminUserRef
|
import com.fabledsword.minstrel.models.AdminUserRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -23,6 +25,7 @@ sealed interface AdminUsersUiState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminUsersViewModel @Inject constructor(
|
class AdminUsersViewModel @Inject constructor(
|
||||||
private val repository: AdminUsersRepository,
|
private val repository: AdminUsersRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||||
@@ -30,6 +33,13 @@ class AdminUsersViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminUsersUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
package com.fabledsword.minstrel.api.endpoints
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.PATCH
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
|
||||||
|
* surface for the tag-enrichment provider settings (#1490). Mirrors the
|
||||||
|
* web integrations "Tag enrichment sources" card. Same shape as the
|
||||||
|
* cover-sources admin surface; kept independent so a new tag source is
|
||||||
|
* added without touching art settings.
|
||||||
|
*/
|
||||||
|
interface AdminTagSourcesApi {
|
||||||
|
@GET("api/admin/tag-sources")
|
||||||
|
suspend fun list(): AdminTagSourcesListWire
|
||||||
|
|
||||||
|
@PATCH("api/admin/tag-sources/{id}")
|
||||||
|
suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire
|
||||||
|
|
||||||
|
@POST("api/admin/tag-sources/{id}/test")
|
||||||
|
suspend fun test(@Path("id") id: String): TestTagSourceWire
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH body. Both fields are nullable + default-null so kotlinx omits the
|
||||||
|
* untouched one (the server reads a missing/null field as "leave
|
||||||
|
* unchanged"): send only `enabled` to toggle, only `apiKey` to set a key.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class UpdateTagSourceBody(
|
||||||
|
val enabled: Boolean? = null,
|
||||||
|
@SerialName("api_key") val apiKey: String? = null,
|
||||||
|
)
|
||||||
@@ -3,9 +3,13 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.DELETE
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
import retrofit2.http.POST
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Query
|
import retrofit2.http.Query
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,4 +34,30 @@ interface DiscoverApi {
|
|||||||
|
|
||||||
@POST("api/requests")
|
@POST("api/requests")
|
||||||
suspend fun createRequest(@Body body: CreateRequestBody)
|
suspend fun createRequest(@Body body: CreateRequestBody)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parks a suggestion — "not right now", NOT a dislike. Time-boxed
|
||||||
|
* server-side (90 days) and never fed into the taste profile.
|
||||||
|
*
|
||||||
|
* [body] must carry the artist's name: candidates are out-of-library, so
|
||||||
|
* the server has no local row to resolve a display name from and returns
|
||||||
|
* 400 without it.
|
||||||
|
*/
|
||||||
|
@POST("api/discover/suggestions/{mbid}/snooze")
|
||||||
|
suspend fun snoozeSuggestion(
|
||||||
|
@Path("mbid") mbid: String,
|
||||||
|
@Body body: SnoozeSuggestionBody,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Brings a parked suggestion back. 404 when it wasn't snoozed. */
|
||||||
|
@DELETE("api/discover/suggestions/{mbid}/snooze")
|
||||||
|
suspend fun unsnoozeSuggestion(@Path("mbid") mbid: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currently-parked suggestions. Server filters expired rows, so every
|
||||||
|
* row returned is still snoozed. This is the only route back to an
|
||||||
|
* un-snooze once the card has left the deck.
|
||||||
|
*/
|
||||||
|
@GET("api/discover/snoozes")
|
||||||
|
suspend fun listSnoozes(): List<SuggestionSnoozeWire>
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-3
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedHomeIndexEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -21,12 +22,32 @@ interface CachedHomeIndexDao {
|
|||||||
)
|
)
|
||||||
suspend fun getBySection(section: String): List<CachedHomeIndexEntity>
|
suspend fun getBySection(section: String): List<CachedHomeIndexEntity>
|
||||||
|
|
||||||
|
/** True when Home has any cached section rows to render. */
|
||||||
|
@Query("SELECT EXISTS(SELECT 1 FROM cached_home_index)")
|
||||||
|
suspend fun hasAny(): Boolean
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
|
suspend fun upsertAll(rows: List<CachedHomeIndexEntity>)
|
||||||
|
|
||||||
/** Replace-all pattern; sync wipes a section then re-inserts. */
|
@Query("DELETE FROM cached_home_index WHERE section IN (:sections)")
|
||||||
@Query("DELETE FROM cached_home_index WHERE section = :section")
|
suspend fun deleteSections(sections: List<String>)
|
||||||
suspend fun deleteBySection(section: String)
|
|
||||||
|
/**
|
||||||
|
* Swaps every listed section's rows in ONE transaction.
|
||||||
|
*
|
||||||
|
* Atomicity is the point, not just tidiness: Room's
|
||||||
|
* InvalidationTracker only notifies observers after the transaction
|
||||||
|
* commits, so [observeBySection] never sees the empty gap between the
|
||||||
|
* delete and the re-insert. Replacing sections one at a time (and
|
||||||
|
* un-transacted) made each Home row emit `emptyList()` — visibly
|
||||||
|
* collapsing — before refilling, and made the seven sections do it in
|
||||||
|
* sequence rather than as a single content swap.
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
suspend fun replaceSections(sections: List<String>, rows: List<CachedHomeIndexEntity>) {
|
||||||
|
deleteSections(sections)
|
||||||
|
if (rows.isNotEmpty()) upsertAll(rows)
|
||||||
|
}
|
||||||
|
|
||||||
@Query("DELETE FROM cached_home_index")
|
@Query("DELETE FROM cached_home_index")
|
||||||
suspend fun clear()
|
suspend fun clear()
|
||||||
|
|||||||
+25
-1
@@ -4,6 +4,7 @@ import androidx.room.Dao
|
|||||||
import androidx.room.Insert
|
import androidx.room.Insert
|
||||||
import androidx.room.OnConflictStrategy
|
import androidx.room.OnConflictStrategy
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
|
import androidx.room.Transaction
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
@@ -35,10 +36,33 @@ interface CachedPlaylistTrackDao {
|
|||||||
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
@Query("SELECT MAX(position) FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||||
suspend fun maxPosition(playlistId: String): Int?
|
suspend fun maxPosition(playlistId: String): Int?
|
||||||
|
|
||||||
/** Replace-all pattern for a playlist; called after a sync delta lands. */
|
|
||||||
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
@Query("DELETE FROM cached_playlist_tracks WHERE playlistId = :playlistId")
|
||||||
suspend fun deleteByPlaylist(playlistId: String)
|
suspend fun deleteByPlaylist(playlistId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces a playlist's whole membership in ONE transaction; called
|
||||||
|
* after a refresh or a sync delta lands.
|
||||||
|
*
|
||||||
|
* Atomic on purpose. Room's InvalidationTracker only notifies observers
|
||||||
|
* after the transaction commits, so [observeByPlaylist] never sees the
|
||||||
|
* empty gap between the delete and the re-insert. Un-transacted, that
|
||||||
|
* gap is a real observed state — it's what made every Home row visibly
|
||||||
|
* collapse to empty and refill before issue #2327 fixed the equivalent
|
||||||
|
* write in `CachedHomeIndexDao`.
|
||||||
|
*
|
||||||
|
* Nothing observes [observeByPlaylist] live today, so this is
|
||||||
|
* pre-emptive: it means making playlist detail cache-first later can't
|
||||||
|
* silently reintroduce that flicker.
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
suspend fun replacePlaylistTracks(
|
||||||
|
playlistId: String,
|
||||||
|
rows: List<CachedPlaylistTrackEntity>,
|
||||||
|
) {
|
||||||
|
deleteByPlaylist(playlistId)
|
||||||
|
if (rows.isNotEmpty()) upsertAll(rows)
|
||||||
|
}
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"DELETE FROM cached_playlist_tracks " +
|
"DELETE FROM cached_playlist_tracks " +
|
||||||
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
|
"WHERE playlistId = :playlistId AND trackId IN (:trackIds)",
|
||||||
|
|||||||
+43
@@ -35,6 +35,12 @@ object MutationKind {
|
|||||||
// background avoids the duplicate + orphan row the old offline-on-stop
|
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||||
// path produced (see 2026-06-11 contract audit).
|
// path produced (see 2026-06-11 contract audit).
|
||||||
const val PLAY_ENDED: String = "play_ended"
|
const val PLAY_ENDED: String = "play_ended"
|
||||||
|
|
||||||
|
// #2374 suggestion snooze. ONE toggle kind rather than separate
|
||||||
|
// snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
|
||||||
|
// an undo collapses to the latest intent instead of replaying as two
|
||||||
|
// opposed calls whose order decides the outcome.
|
||||||
|
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -152,6 +158,25 @@ class MutationQueue @Inject constructor(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a suggestion snooze (or its undo) for replay. [desiredSnoozed]
|
||||||
|
* is the TARGET state, so repeated taps collapse to one replay.
|
||||||
|
*
|
||||||
|
* [name] is carried even for an un-snooze, where the server ignores it,
|
||||||
|
* so a single payload shape serves both directions.
|
||||||
|
*/
|
||||||
|
suspend fun enqueueSuggestionSnoozeToggle(
|
||||||
|
mbid: String,
|
||||||
|
name: String,
|
||||||
|
desiredSnoozed: Boolean,
|
||||||
|
): Long = insertUserDriven(
|
||||||
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||||
|
json.encodeToString(
|
||||||
|
SuggestionSnoozeTogglePayload.serializer(),
|
||||||
|
SuggestionSnoozeTogglePayload(mbid, name, desiredSnoozed),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||||
MutationKind.REQUEST_CANCEL,
|
MutationKind.REQUEST_CANCEL,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
@@ -192,6 +217,21 @@ class MutationQueue @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
|
||||||
|
* `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
|
||||||
|
* the replayer can collapse repeated toggles for one candidate down to the
|
||||||
|
* last intent. Both directions are idempotent server-side: re-snoozing
|
||||||
|
* extends the window, and un-snoozing something already back is a 404 the
|
||||||
|
* replayer treats as permanent (nothing left to do).
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SuggestionSnoozeTogglePayload(
|
||||||
|
val mbid: String,
|
||||||
|
val name: String,
|
||||||
|
val desiredSnoozed: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
* Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
|
||||||
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
* `DELETE /api/quarantine/{trackId}` call lost during a connectivity
|
||||||
@@ -239,6 +279,9 @@ data class PlayOfflinePayload(
|
|||||||
val atIso: String,
|
val atIso: String,
|
||||||
val durationPlayedMs: Long,
|
val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning; null on payloads queued
|
||||||
|
// before this field existed (decodes to null → server stores NULL).
|
||||||
|
val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+70
-24
@@ -16,6 +16,7 @@ import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
|||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||||
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
@@ -114,11 +115,12 @@ class MutationReplayer @Inject constructor(
|
|||||||
|
|
||||||
private suspend fun drain() {
|
private suspend fun drain() {
|
||||||
val rows = dao.getAll()
|
val rows = dao.getAll()
|
||||||
// Collapse superseded like-toggles: only the latest desired state per
|
// Collapse superseded toggles (likes, suggestion snoozes): only the
|
||||||
// (entity) is replayed; older toggles for the same entity are dropped
|
// latest desired state per entity is replayed; older toggles for the
|
||||||
// unsent. Without this, partial-failure + differential retry could
|
// same entity are dropped unsent. Without this, partial-failure +
|
||||||
// replay an older toggle last and invert the final like state.
|
// differential retry could replay an older toggle last and invert the
|
||||||
val superseded = supersededLikeToggleIds(rows)
|
// final state — a snooze the user already undid would come back.
|
||||||
|
val superseded = supersededToggleIds(rows, json)
|
||||||
for (row in rows) {
|
for (row in rows) {
|
||||||
if (row.id in superseded) {
|
if (row.id in superseded) {
|
||||||
dao.delete(row.id)
|
dao.delete(row.id)
|
||||||
@@ -131,25 +133,6 @@ class MutationReplayer @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
|
|
||||||
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
|
|
||||||
val latestByEntity = HashMap<String, Long>()
|
|
||||||
val superseded = HashSet<Long>()
|
|
||||||
rows.asSequence()
|
|
||||||
.filter { it.kind == MutationKind.LIKE_TOGGLE }
|
|
||||||
.forEach { row ->
|
|
||||||
val decoded = runCatching {
|
|
||||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
|
||||||
}.getOrNull()
|
|
||||||
if (decoded != null) {
|
|
||||||
val key = "${decoded.entityType}:${decoded.entityId}"
|
|
||||||
// `rows` is ascending by id, so a prior entry is always older.
|
|
||||||
latestByEntity.put(key, row.id)?.let(superseded::add)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return superseded
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
||||||
dispatch(row)
|
dispatch(row)
|
||||||
} catch (e: HttpException) {
|
} catch (e: HttpException) {
|
||||||
@@ -182,6 +165,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
|
||||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
else -> Outcome.DROP
|
else -> Outcome.DROP
|
||||||
}
|
}
|
||||||
@@ -254,6 +238,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
at = decoded.atIso,
|
at = decoded.atIso,
|
||||||
durationPlayedMs = decoded.durationPlayedMs,
|
durationPlayedMs = decoded.durationPlayedMs,
|
||||||
source = decoded.source,
|
source = decoded.source,
|
||||||
|
deviceClass = decoded.deviceClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
@@ -276,6 +261,24 @@ class MutationReplayer @Inject constructor(
|
|||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replays a suggestion snooze in whichever direction the payload asks for.
|
||||||
|
*
|
||||||
|
* The un-snooze branch can legitimately 404 (the row already lapsed, or a
|
||||||
|
* previous attempt landed and the response was lost). [outcomeFor] classes
|
||||||
|
* 404 as permanent → DROP, which is right: the user's intended end state
|
||||||
|
* already holds, so there is nothing left to send.
|
||||||
|
*/
|
||||||
|
private suspend fun dispatchSuggestionSnoozeToggle(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), payload)
|
||||||
|
if (decoded.desiredSnoozed) {
|
||||||
|
discoverApi.snoozeSuggestion(decoded.mbid, SnoozeSuggestionBody(name = decoded.name))
|
||||||
|
} else {
|
||||||
|
discoverApi.unsnoozeSuggestion(decoded.mbid)
|
||||||
|
}
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
@@ -296,3 +299,46 @@ class MutationReplayer @Inject constructor(
|
|||||||
const val HTTP_TOO_MANY = 429
|
const val HTTP_TOO_MANY = 429
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||||
|
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||||
|
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
||||||
|
* replaying a stale one last would invert the final state.
|
||||||
|
*
|
||||||
|
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||||
|
* instance. [rows] must be ascending by id (FIFO), which is what
|
||||||
|
* `CachedMutationDao.getAll()` returns.
|
||||||
|
*/
|
||||||
|
internal fun supersededToggleIds(rows: List<CachedMutationEntity>, json: Json): Set<Long> {
|
||||||
|
val latestByEntity = HashMap<String, Long>()
|
||||||
|
val superseded = HashSet<Long>()
|
||||||
|
rows.asSequence()
|
||||||
|
.mapNotNull { row -> toggleKeyOf(row, json)?.let { key -> key to row.id } }
|
||||||
|
.forEach { (key, id) ->
|
||||||
|
// Ascending ids mean a prior entry for this key is always older.
|
||||||
|
latestByEntity.put(key, id)?.let(superseded::add)
|
||||||
|
}
|
||||||
|
return superseded
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse key for a toggle row, or null when the row isn't a toggle — or its
|
||||||
|
* payload won't decode. Undecodable rows are deliberately left alone rather
|
||||||
|
* than grouped under a shared "corrupt" key, so one bad row can't suppress a
|
||||||
|
* good one behind it; the dispatcher DROPs it on its own.
|
||||||
|
*
|
||||||
|
* The kind is part of the key so two toggle kinds can never collide on the
|
||||||
|
* same entity id.
|
||||||
|
*/
|
||||||
|
private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (row.kind) {
|
||||||
|
MutationKind.LIKE_TOGGLE -> runCatching {
|
||||||
|
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { "${row.kind}:${it.entityType}:${it.entityId}" }
|
||||||
|
|
||||||
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> runCatching {
|
||||||
|
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|||||||
+18
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.cache.sync
|
package com.fabledsword.minstrel.cache.sync
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
@@ -17,6 +18,9 @@ import com.fabledsword.minstrel.models.wire.SyncAlbumWire
|
|||||||
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
||||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
@@ -64,6 +68,17 @@ class SyncController @Inject constructor(
|
|||||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val lastSyncErrorInternal = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human copy for the most recent sync failure; null after any clean
|
||||||
|
* pass. Lets the Library screen distinguish "empty because the first
|
||||||
|
* sync failed" (show error + retry) from "genuinely empty library"
|
||||||
|
* (show welcome copy) — a failed sync over a populated cache stays
|
||||||
|
* silent, since the cached content is still the better surface.
|
||||||
|
*/
|
||||||
|
val lastSyncError: StateFlow<String?> = lastSyncErrorInternal.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
authStore.sessionCookie
|
||||||
@@ -82,11 +97,13 @@ class SyncController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
/** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */
|
||||||
suspend fun syncSafe() {
|
suspend fun syncSafe() {
|
||||||
if (!mutex.tryLock()) return
|
if (!mutex.tryLock()) return
|
||||||
try {
|
try {
|
||||||
runCatching { sync() }
|
runCatching { sync() }
|
||||||
|
.onSuccess { lastSyncErrorInternal.value = null }
|
||||||
|
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
|
||||||
} finally {
|
} finally {
|
||||||
mutex.unlock()
|
mutex.unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits once each time server health RETURNS to [ServerHealth.Healthy]
|
||||||
|
* after the collector subscribed — the StateFlow's replayed current value
|
||||||
|
* is dropped, so only genuine down→up transitions fire (a screen that
|
||||||
|
* subscribes while already Healthy doesn't double-load).
|
||||||
|
*
|
||||||
|
* This is the screen-level half of the recovery idiom the app-lifetime
|
||||||
|
* singletons (SyncController / MutationReplayer / DiagnosticsUploader)
|
||||||
|
* already use: a ViewModel collects this in its viewModelScope and re-runs
|
||||||
|
* its load, so a surface that failed while the server was unreachable
|
||||||
|
* heals itself the moment connectivity returns instead of sitting in the
|
||||||
|
* failed state until a manual pull-to-refresh.
|
||||||
|
*/
|
||||||
|
fun NetworkStatusController.recoveries(): Flow<Unit> = state
|
||||||
|
.map { it == ServerHealth.Healthy }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.drop(1)
|
||||||
|
.filter { it }
|
||||||
|
.map { }
|
||||||
+81
@@ -7,10 +7,14 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
|||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.models.SeedContributionRef
|
import com.fabledsword.minstrel.models.SeedContributionRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
|
import retrofit2.HttpException
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -46,6 +50,69 @@ class DiscoverRepository @Inject constructor(
|
|||||||
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
||||||
api.listSuggestions().map { it.toDomain() }
|
api.listSuggestions().map { it.toDomain() }
|
||||||
|
|
||||||
|
suspend fun listSnoozes(): List<SuggestionSnoozeRef> =
|
||||||
|
api.listSnoozes().map { it.toDomain() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parks a suggestion ("not right now"). Offline-first per rule #100: on
|
||||||
|
* transport failure the target state is queued for the replayer rather
|
||||||
|
* than dropped.
|
||||||
|
*
|
||||||
|
* Always reports success to the caller. Unlike a request, a snooze has no
|
||||||
|
* meaningful failed state to show — the user asked for a card to go away,
|
||||||
|
* and it will, either now or when the queue drains.
|
||||||
|
*/
|
||||||
|
suspend fun snoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
desiredSnoozed = true,
|
||||||
|
) { api.snoozeSuggestion(mbid, SnoozeSuggestionBody(name = name)) }
|
||||||
|
|
||||||
|
/** Brings a parked suggestion back. Same offline-first contract. */
|
||||||
|
suspend fun unsnoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
desiredSnoozed = false,
|
||||||
|
) { api.unsnoozeSuggestion(mbid) }
|
||||||
|
|
||||||
|
private suspend fun toggleSnooze(
|
||||||
|
mbid: String,
|
||||||
|
name: String,
|
||||||
|
desiredSnoozed: Boolean,
|
||||||
|
call: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
call()
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
// A 4xx is the server's considered answer, not a lost call, so
|
||||||
|
// queueing it would be wrong twice over: the replay is guaranteed
|
||||||
|
// to fail again, and the enqueue would raise a "will sync when
|
||||||
|
// online" snackbar for something already settled. The common case
|
||||||
|
// is a 404 from un-snoozing a row that already lapsed — which is
|
||||||
|
// the end state the user wanted anyway.
|
||||||
|
if (!isPermanent(e.code())) {
|
||||||
|
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Transport failure — intentional swallow, same offline-first
|
||||||
|
// rationale as createRequest above. The queue carries the desired
|
||||||
|
// STATE, so a later undo supersedes this rather than fighting it
|
||||||
|
// on replay.
|
||||||
|
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors MutationReplayer's classification so the enqueue decision here
|
||||||
|
* and the drop decision there can't disagree: 4xx is permanent except the
|
||||||
|
* two "retry me" statuses.
|
||||||
|
*/
|
||||||
|
private fun isPermanent(code: Int): Boolean =
|
||||||
|
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||||
|
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
||||||
|
|
||||||
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
||||||
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||||
|
|
||||||
@@ -85,6 +152,13 @@ class DiscoverRepository @Inject constructor(
|
|||||||
RequestOutcome.QUEUED
|
RequestOutcome.QUEUED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val HTTP_CLIENT_ERR_MIN = 400
|
||||||
|
const val HTTP_CLIENT_ERR_MAX = 499
|
||||||
|
const val HTTP_TIMEOUT = 408
|
||||||
|
const val HTTP_TOO_MANY = 429
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mappers (internal — wire types stay out of UI) ──
|
// ── Mappers (internal — wire types stay out of UI) ──
|
||||||
@@ -105,6 +179,7 @@ private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggest
|
|||||||
name = name,
|
name = name,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
attribution = attribution.map { it.toDomain() },
|
attribution = attribution.map { it.toDomain() },
|
||||||
|
matchedTags = matchedTags,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
||||||
@@ -112,6 +187,12 @@ private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContribut
|
|||||||
isLiked = isLiked,
|
isLiked = isLiked,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
snoozedUntil = snoozedUntil,
|
||||||
|
)
|
||||||
|
|
||||||
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
artistMbid = artistMbid,
|
artistMbid = artistMbid,
|
||||||
|
|||||||
@@ -40,7 +40,9 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
|||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -103,12 +105,25 @@ private fun DiscoverBody(
|
|||||||
ResultsState.Idle -> SuggestionsPane(
|
ResultsState.Idle -> SuggestionsPane(
|
||||||
state = state.suggestions,
|
state = state.suggestions,
|
||||||
locallyRequestedMbids = state.locallyRequestedMbids,
|
locallyRequestedMbids = state.locallyRequestedMbids,
|
||||||
|
snoozeUi = SnoozeUi(
|
||||||
|
locallySnoozedMbids = state.locallySnoozedMbids,
|
||||||
|
snoozes = state.snoozes,
|
||||||
|
// No snackbar on snooze: the row itself flips to "Not
|
||||||
|
// right now" with an Undo, so a snackbar would only
|
||||||
|
// repeat what the user can already see — and cover the
|
||||||
|
// next row while doing it.
|
||||||
|
onSnooze = { s -> scope.launch { viewModel.snoozeSuggestion(s) } },
|
||||||
|
onUnsnooze = { mbid, name ->
|
||||||
|
scope.launch { viewModel.unsnoozeSuggestion(mbid, name) }
|
||||||
|
},
|
||||||
|
),
|
||||||
onRequest = { s ->
|
onRequest = { s ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val outcome = viewModel.requestSuggestion(s)
|
val outcome = viewModel.requestSuggestion(s)
|
||||||
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRetry = { viewModel.loadSuggestions() },
|
||||||
)
|
)
|
||||||
ResultsState.Loading -> LoadingCentered()
|
ResultsState.Loading -> LoadingCentered()
|
||||||
is ResultsState.Loaded -> ResultsList(
|
is ResultsState.Loaded -> ResultsList(
|
||||||
@@ -121,7 +136,11 @@ private fun DiscoverBody(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}")
|
is ResultsState.Error -> ErrorRetry(
|
||||||
|
title = "Search failed",
|
||||||
|
message = r.message,
|
||||||
|
onRetry = viewModel::runSearch,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,17 +191,36 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The snooze surface's data and callbacks, bundled rather than threaded
|
||||||
|
* through as four more parameters — the pane grew from one action to three
|
||||||
|
* with slice 4 and the signatures stopped being readable.
|
||||||
|
*/
|
||||||
|
private data class SnoozeUi(
|
||||||
|
val locallySnoozedMbids: Set<String>,
|
||||||
|
val snoozes: List<SuggestionSnoozeRef>,
|
||||||
|
val onSnooze: (ArtistSuggestionRef) -> Unit,
|
||||||
|
val onUnsnooze: (String, String) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionsPane(
|
private fun SuggestionsPane(
|
||||||
state: SuggestionState,
|
state: SuggestionState,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
|
snoozeUi: SnoozeUi,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SuggestionState.Loading -> LoadingCentered()
|
SuggestionState.Loading -> LoadingCentered()
|
||||||
is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.")
|
is SuggestionState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load suggestions",
|
||||||
|
message = state.message,
|
||||||
|
onRetry = onRetry,
|
||||||
|
)
|
||||||
is SuggestionState.Loaded -> SuggestionsList(
|
is SuggestionState.Loaded -> SuggestionsList(
|
||||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||||
|
snoozeUi = snoozeUi,
|
||||||
onRequest = onRequest,
|
onRequest = onRequest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -191,6 +229,7 @@ private fun SuggestionsPane(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionsList(
|
private fun SuggestionsList(
|
||||||
items: List<ArtistSuggestionRef>,
|
items: List<ArtistSuggestionRef>,
|
||||||
|
snoozeUi: SnoozeUi,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -199,13 +238,61 @@ private fun SuggestionsList(
|
|||||||
) {
|
) {
|
||||||
item { SuggestionsHeader() }
|
item { SuggestionsHeader() }
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
item { CenteredMessage("Listen to or like an artist to fill this in.") }
|
// An empty deck used to mean one thing — no listening signal yet.
|
||||||
|
// With snoozing it can also mean "you parked them all", and telling
|
||||||
|
// that user to go listen to something would be wrong advice.
|
||||||
|
item {
|
||||||
|
CenteredMessage(
|
||||||
|
if (snoozeUi.snoozes.isEmpty()) {
|
||||||
|
"Listen to or like an artist to fill this in."
|
||||||
|
} else {
|
||||||
|
"Nothing new right now — the artists you've parked are below."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
items(items = items, key = { it.mbid }) { s ->
|
items(items = items, key = { it.mbid }) { s ->
|
||||||
SuggestionTile(s = s, onRequest = { onRequest(s) })
|
SuggestionTile(
|
||||||
|
s = s,
|
||||||
|
snoozed = s.mbid in snoozeUi.locallySnoozedMbids,
|
||||||
|
onRequest = { onRequest(s) },
|
||||||
|
onSnooze = { snoozeUi.onSnooze(s) },
|
||||||
|
onUnsnooze = { snoozeUi.onUnsnooze(s.mbid, s.name) },
|
||||||
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Parked candidates live at the bottom of the same scroll, not behind a
|
||||||
|
// separate screen: it's a short list the user rarely needs, but it must
|
||||||
|
// be reachable — a snoozed candidate is gone from the deck above, so
|
||||||
|
// this is the only way back to it.
|
||||||
|
if (snoozeUi.snoozes.isNotEmpty()) {
|
||||||
|
item { SnoozedHeader() }
|
||||||
|
items(items = snoozeUi.snoozes, key = { "snoozed-${it.mbid}" }) { row ->
|
||||||
|
SnoozedTile(
|
||||||
|
row = row,
|
||||||
|
onUnsnooze = { snoozeUi.onUnsnooze(row.mbid, row.name) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SnoozedHeader() {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Not right now",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "These come back on their own. Nothing here counts against your taste profile.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -24,14 +26,22 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.composables.icons.lucide.Clock
|
||||||
import com.composables.icons.lucide.Disc3
|
import com.composables.icons.lucide.Disc3
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
internal fun SuggestionTile(
|
||||||
|
s: ArtistSuggestionRef,
|
||||||
|
snoozed: Boolean,
|
||||||
|
onRequest: () -> Unit,
|
||||||
|
onSnooze: () -> Unit,
|
||||||
|
onUnsnooze: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -48,9 +58,13 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (s.attributionText.isNotEmpty()) {
|
// Once parked, the "because you liked X" line is no longer the
|
||||||
|
// useful thing to say — confirming what just happened is.
|
||||||
|
val secondary =
|
||||||
|
if (snoozed) "Not right now — hidden for a while" else s.reasonText
|
||||||
|
if (secondary.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = s.attributionText,
|
text = secondary,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
@@ -58,7 +72,52 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button(onClick = onRequest) { Text("Request") }
|
if (snoozed) {
|
||||||
|
TextButton(onClick = onUnsnooze) { Text("Undo") }
|
||||||
|
} else {
|
||||||
|
Button(onClick = onRequest) { Text("Request") }
|
||||||
|
IconButton(onClick = onSnooze) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.Clock,
|
||||||
|
// Rule #101: the label states what happens, and passes no
|
||||||
|
// judgement on the music. Never "not for me".
|
||||||
|
contentDescription = "Not right now — hide ${s.name} for a while",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the parked list. This exists because a snoozed candidate is by
|
||||||
|
* definition absent from the deck above, so without it there is no route back
|
||||||
|
* to an un-snooze once the card has gone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = row.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Back ${row.returnsIn()}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onUnsnooze) { Text("Bring back") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package com.fabledsword.minstrel.discover.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -25,6 +28,17 @@ data class DiscoverState(
|
|||||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||||
val results: ResultsState = ResultsState.Idle,
|
val results: ResultsState = ResultsState.Idle,
|
||||||
val locallyRequestedMbids: Set<String> = emptySet(),
|
val locallyRequestedMbids: Set<String> = emptySet(),
|
||||||
|
/**
|
||||||
|
* Parked candidates, for the manage list under the feed. Empty is the
|
||||||
|
* normal case and hides the section entirely.
|
||||||
|
*/
|
||||||
|
val snoozes: List<SuggestionSnoozeRef> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Just-snoozed MBIDs. These keep their row visible showing an Undo rather
|
||||||
|
* than yanking it out from under the user's finger; the row is gone on the
|
||||||
|
* next load, and [snoozes] is the way back after that.
|
||||||
|
*/
|
||||||
|
val locallySnoozedMbids: Set<String> = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SuggestionState {
|
sealed interface SuggestionState {
|
||||||
@@ -46,6 +60,7 @@ sealed interface ResultsState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DiscoverViewModel @Inject constructor(
|
class DiscoverViewModel @Inject constructor(
|
||||||
private val repository: DiscoverRepository,
|
private val repository: DiscoverRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(DiscoverState())
|
private val internal = MutableStateFlow(DiscoverState())
|
||||||
@@ -53,6 +68,15 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadSuggestions()
|
loadSuggestions()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-run whichever pane
|
||||||
|
// failed while the server was unreachable once health returns.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
val s = internal.value
|
||||||
|
if (s.suggestions is SuggestionState.Error) loadSuggestions()
|
||||||
|
if (s.results is ResultsState.Error) runSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
@@ -84,6 +108,47 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Refresh the parked list alongside the deck: a snooze made on another
|
||||||
|
// client should show up here, and one whose window lapsed should drop
|
||||||
|
// off. Sequenced after the deck load rather than raced with it so the
|
||||||
|
// two panes can't disagree about a candidate mid-refresh.
|
||||||
|
loadSnoozes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the parked list. Failure is deliberately silent: this is a
|
||||||
|
* secondary pane, and an error banner for it would sit above the suggestion
|
||||||
|
* feed the user actually came for. The list stays as-is and the next
|
||||||
|
* refresh retries.
|
||||||
|
*/
|
||||||
|
private suspend fun loadSnoozes() {
|
||||||
|
try {
|
||||||
|
val rows = repository.listSnoozes()
|
||||||
|
internal.update { it.copy(snoozes = rows) }
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Keep whatever we last showed rather than blanking the section.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parks a suggestion. Flips the row locally first so the tap registers
|
||||||
|
* immediately; the repository handles the offline case, so there is no
|
||||||
|
* failure branch to revert here — unlike the web client, where the fetch
|
||||||
|
* either lands or doesn't.
|
||||||
|
*/
|
||||||
|
suspend fun snoozeSuggestion(s: ArtistSuggestionRef) {
|
||||||
|
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids + s.mbid) }
|
||||||
|
repository.snoozeSuggestion(s.mbid, s.name)
|
||||||
|
loadSnoozes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brings a parked suggestion back, from either the card or the list. */
|
||||||
|
suspend fun unsnoozeSuggestion(mbid: String, name: String) {
|
||||||
|
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids - mbid) }
|
||||||
|
repository.unsnoozeSuggestion(mbid, name)
|
||||||
|
loadSnoozes()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun runSearch() {
|
fun runSearch() {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.history.data.HistoryEntry
|
import com.fabledsword.minstrel.history.data.HistoryEntry
|
||||||
import com.fabledsword.minstrel.history.data.HistoryRepository
|
import com.fabledsword.minstrel.history.data.HistoryRepository
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
@@ -26,6 +28,7 @@ import com.fabledsword.minstrel.player.PlayerController
|
|||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -56,6 +59,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HistoryTabViewModel @Inject constructor(
|
class HistoryTabViewModel @Inject constructor(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -82,6 +86,11 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -116,9 +125,10 @@ fun HistoryTab(
|
|||||||
title = "No listening history yet",
|
title = "No listening history yet",
|
||||||
body = "Play something — your recent plays will show up here.",
|
body = "Play something — your recent plays will show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load history",
|
title = "Couldn't load history",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HistoryList(
|
is UiState.Success -> HistoryList(
|
||||||
entries = s.data,
|
entries = s.data,
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import com.fabledsword.minstrel.models.TrackRef
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -34,9 +36,10 @@ import javax.inject.Singleton
|
|||||||
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
||||||
* per-item tile providers.
|
* per-item tile providers.
|
||||||
*
|
*
|
||||||
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section
|
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
|
||||||
* in-place (delete-then-insert, so the section Flows re-fire), and
|
* one transaction (so the rows update together in a single emission
|
||||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
* rather than collapsing and refilling), and pre-warms the top artists
|
||||||
|
* via [HomeArtistPrewarmer].
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
// Per-section observe accessors (one per Home row) inflate the function
|
// Per-section observe accessors (one per Home row) inflate the function
|
||||||
@@ -85,72 +88,98 @@ class HomeRepository @Inject constructor(
|
|||||||
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
||||||
|
|
||||||
|
/** True when the index cache already has content on screen to protect. */
|
||||||
|
suspend fun hasCachedIndex(): Boolean = homeIndexDao.hasAny()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls /api/home/index, replaces each cached_home_index section,
|
* Pulls /api/home/index and swaps every cached_home_index section in
|
||||||
* and pre-warms the top artists. The section Flows re-fire on the
|
* a single transaction, then pre-warms the top artists. Missing
|
||||||
* index change; missing entity rows hydrate via the on-miss path.
|
* entity rows hydrate via the on-miss path.
|
||||||
|
*
|
||||||
|
* One transaction for all seven sections is deliberate: Room notifies
|
||||||
|
* observers once, on commit, so Home swaps from the old content to
|
||||||
|
* the new in a single emission. Per-section, un-transacted writes
|
||||||
|
* made every row visibly collapse to empty and refill, one after
|
||||||
|
* another (issue #2327).
|
||||||
*/
|
*/
|
||||||
suspend fun refreshIndex() {
|
suspend fun refreshIndex() {
|
||||||
val wire = api.getHomeIndex()
|
val wire = api.getHomeIndex()
|
||||||
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums)
|
homeIndexDao.replaceSections(
|
||||||
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums)
|
sections = ALL_SECTIONS,
|
||||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
rows = rowsFor(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) +
|
||||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
rowsFor(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) +
|
||||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
rowsFor(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) +
|
||||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
|
rowsFor(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) +
|
||||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
|
rowsFor(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) +
|
||||||
|
rowsFor(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums) +
|
||||||
|
rowsFor(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists),
|
||||||
|
)
|
||||||
prewarmer.warm(
|
prewarmer.warm(
|
||||||
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
private fun rowsFor(
|
||||||
homeIndexDao.deleteBySection(section)
|
section: String,
|
||||||
if (ids.isEmpty()) return
|
entityType: String,
|
||||||
homeIndexDao.upsertAll(
|
ids: List<String>,
|
||||||
ids.mapIndexed { index, id ->
|
): List<CachedHomeIndexEntity> = ids.mapIndexed { index, id ->
|
||||||
CachedHomeIndexEntity(
|
CachedHomeIndexEntity(
|
||||||
section = section,
|
section = section,
|
||||||
position = index,
|
position = index,
|
||||||
entityType = entityType,
|
entityType = entityType,
|
||||||
entityId = id,
|
entityId = id,
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The section's ordered entity ids, deduplicated.
|
||||||
|
*
|
||||||
|
* Room re-runs the query on every write to `cached_home_index` — and
|
||||||
|
* `CachedHomeIndexEntity.fetchedAt` is stamped fresh each time — so
|
||||||
|
* comparing whole rows would call every rewrite a change. Comparing
|
||||||
|
* the id list instead means a section whose contents didn't actually
|
||||||
|
* move never restarts the `flatMapLatest` below, which would
|
||||||
|
* otherwise tear down and rebuild all of its tiles' hydration flows
|
||||||
|
* and flicker unchanged tiles (issue #2327).
|
||||||
|
*/
|
||||||
|
private fun observeSectionIds(section: String): Flow<List<String>> =
|
||||||
|
homeIndexDao.observeBySection(section)
|
||||||
|
.map { rows -> rows.map { it.entityId } }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeAlbum(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeArtist(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeTrack(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,5 +192,16 @@ class HomeRepository @Inject constructor(
|
|||||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
||||||
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
||||||
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
||||||
|
|
||||||
|
/** Every section [refreshIndex] owns — the unit of one atomic swap. */
|
||||||
|
val ALL_SECTIONS = listOf(
|
||||||
|
SECTION_RECENTLY_ADDED_ALBUMS,
|
||||||
|
SECTION_REDISCOVER_ALBUMS,
|
||||||
|
SECTION_REDISCOVER_ARTISTS,
|
||||||
|
SECTION_MOST_PLAYED_TRACKS,
|
||||||
|
SECTION_LAST_PLAYED_ARTISTS,
|
||||||
|
SECTION_YOU_MIGHT_LIKE_ALBUMS,
|
||||||
|
SECTION_YOU_MIGHT_LIKE_ARTISTS,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
package com.fabledsword.minstrel.home.ui
|
package com.fabledsword.minstrel.home.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -27,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -34,6 +43,7 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -55,6 +65,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
@@ -75,28 +86,38 @@ import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
|
|||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
|
import com.fabledsword.minstrel.shared.UpdateVeilController
|
||||||
|
import com.fabledsword.minstrel.shared.VeilOutcome
|
||||||
|
import com.fabledsword.minstrel.shared.VeilSessionResult
|
||||||
|
import com.fabledsword.minstrel.shared.VeilSettleState
|
||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LocalArtSettleTracker
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
@@ -108,6 +129,22 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
|||||||
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
||||||
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
||||||
|
|
||||||
|
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
|
||||||
|
// appears at all — only when a refresh actually changes something — and how
|
||||||
|
// long it stays, by watching the screen settle rather than by a fixed delay,
|
||||||
|
// which lowered it while tiles and artwork were still landing (#2327).
|
||||||
|
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
||||||
|
private const val VEIL_WIPE_MS = 280
|
||||||
|
private const val VEIL_ALPHA = 0.96f
|
||||||
|
private const val VEIL_SPINNER_DP = 22
|
||||||
|
private const val VEIL_SPINNER_STROKE_DP = 2
|
||||||
|
private const val VEIL_LABEL_GAP_DP = 12
|
||||||
|
|
||||||
|
// Backstop on how long a manual pull keeps its own indicator while waiting
|
||||||
|
// for its successor — the veil, or the "already up to date" snackbar — so the
|
||||||
|
// two never both vanish for a frame mid-handoff.
|
||||||
|
private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class HomeSections(
|
data class HomeSections(
|
||||||
@@ -162,23 +199,29 @@ class HomeViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
private val snackbarMessages = Channel<String>(Channel.BUFFERED)
|
||||||
|
|
||||||
/** Transient snackbar messages from offline-pool taps. */
|
/**
|
||||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
* Transient snackbar messages: offline-pool taps, playback failures, and
|
||||||
|
* the outcome of a refresh the user explicitly asked for.
|
||||||
|
*/
|
||||||
|
val transientMessages: Flow<String> = snackbarMessages.receiveAsFlow()
|
||||||
|
|
||||||
init {
|
/**
|
||||||
refresh()
|
* Copy for the most recent /home/index refresh failure; null once a
|
||||||
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
* refresh succeeds. Folded into [uiState] so an empty cache + failed
|
||||||
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
* refresh renders as Error (with retry) instead of masquerading as
|
||||||
// and You-might-like rows reflect the new snapshot without a manual
|
* the "Welcome to Minstrel" empty state.
|
||||||
// reload. Mirrors the web SSE consumer.
|
*/
|
||||||
viewModelScope.launch {
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
eventsStream.events
|
|
||||||
.filter { it.kind == "playlist.system_rebuilt" }
|
/**
|
||||||
.collect { refresh() }
|
* 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 artTracker = ArtSettleTracker()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
||||||
@@ -191,7 +234,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
||||||
}.shuffled()
|
}.shuffled()
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
poolMessages.trySend("No cached ${kind.label} tracks yet")
|
snackbarMessages.trySend("No cached ${kind.label} tracks yet")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
||||||
}
|
}
|
||||||
@@ -228,14 +271,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
||||||
if (detail.tracks.isEmpty()) {
|
if (detail.tracks.isEmpty()) {
|
||||||
poolMessages.trySend("This album has no tracks to play.")
|
snackbarMessages.trySend("This album has no tracks to play.")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
poolMessages.trySend(
|
snackbarMessages.trySend(
|
||||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -253,14 +296,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
poolMessages.trySend("This artist has no tracks to play.")
|
snackbarMessages.trySend("This artist has no tracks to play.")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
poolMessages.trySend(
|
snackbarMessages.trySend(
|
||||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -278,26 +321,67 @@ class HomeViewModel @Inject constructor(
|
|||||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
||||||
poolMessages.trySend(it)
|
snackbarMessages.trySend(it)
|
||||||
}
|
}
|
||||||
}.join()
|
}.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls both /home/index and the playlists list. Returns the Job
|
* Pulls /home/index, the playlists list and the system-playlist
|
||||||
* for the combined refresh so a pull-to-refresh wrapper can await
|
* status. Returns true when the load-bearing /home/index pull
|
||||||
* actual completion before hiding the indicator.
|
* succeeded — the veil controller retries on false and reports the
|
||||||
|
* outcome, so this must report failure rather than swallow it.
|
||||||
*/
|
*/
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
private suspend fun runRefresh(): Boolean = coroutineScope {
|
||||||
val home = launch { runCatching { homeRepository.refreshIndex() } }
|
// /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 lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||||
val status = launch {
|
val status = launch {
|
||||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||||
.onSuccess { systemStatusInternal.value = it }
|
.onSuccess { systemStatusInternal.value = it }
|
||||||
}
|
}
|
||||||
home.join()
|
|
||||||
lists.join()
|
lists.join()
|
||||||
status.join()
|
status.join()
|
||||||
|
home.await()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Error state's explicit Retry button. User-initiated, so it gets
|
||||||
|
* the controller's retries and reports its outcome; over an empty cache
|
||||||
|
* there's no content to protect, so no veil goes up.
|
||||||
|
*/
|
||||||
|
fun retry() = veil.request(userInitiated = true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual pull-to-refresh. Goes behind the veil like every other refresh
|
||||||
|
* (operator call, 2026-07-31: the churn a pull causes is identical to
|
||||||
|
* the automatic paths, and a small spinner didn't hide it).
|
||||||
|
*
|
||||||
|
* Suspends until the veil has taken over OR the session has finished,
|
||||||
|
* so the pull indicator hands off to exactly one successor: the veil if
|
||||||
|
* content changed, the "Already up to date" snackbar if it didn't. The
|
||||||
|
* timeout is only a backstop against a session that outlives it.
|
||||||
|
*/
|
||||||
|
suspend fun refreshFromPull() {
|
||||||
|
val before = veil.finishedSessions.value
|
||||||
|
veil.request(userInitiated = true)
|
||||||
|
withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) {
|
||||||
|
combine(veil.visible, veil.finishedSessions) { veiled, finished ->
|
||||||
|
veiled || finished != before
|
||||||
|
}.first { it }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<HomeSections>> =
|
val uiState: StateFlow<UiState<HomeSections>> =
|
||||||
@@ -336,10 +420,136 @@ class HomeViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun combineHomeFlows() =
|
private fun combineHomeFlows() =
|
||||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
combine(
|
||||||
|
observeHomeSections(),
|
||||||
|
playlistsRepository.observeAll(),
|
||||||
|
refreshError,
|
||||||
|
) { sections, playlists, err ->
|
||||||
val merged = sections.copy(playlists = playlists)
|
val merged = sections.copy(playlists = playlists)
|
||||||
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
|
when {
|
||||||
|
!merged.isAllEmpty -> UiState.Success(merged)
|
||||||
|
err != null -> UiState.Error(err)
|
||||||
|
else -> UiState.Empty
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Updating veil ───────────────────────────────────────────────
|
||||||
|
// Declared after uiState: these initialisers read it, and Kotlin runs
|
||||||
|
// property initialisers and init blocks in declaration order.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the veil watches to decide Home has stopped moving: the whole
|
||||||
|
* rendered state, plus how many covers are still loading.
|
||||||
|
*
|
||||||
|
* [UiState.Success] wraps a [HomeSections] data class, so any visible
|
||||||
|
* change — a section swapping ids, one tile hydrating from skeleton to
|
||||||
|
* album — changes this value and re-arms the veil's quiet window.
|
||||||
|
*
|
||||||
|
* Unhydrated tiles deliberately do NOT gate `quiescent`. A tile whose
|
||||||
|
* on-miss fetch soft-fails keeps a null value indefinitely
|
||||||
|
* ([MetadataProvider] swallows those errors), so treating "no
|
||||||
|
* skeletons left" as the settle condition would pin the veil to its
|
||||||
|
* hard ceiling on every refresh. They're covered by the content key
|
||||||
|
* instead: each tile that lands re-arms the window, and once they stop
|
||||||
|
* landing the screen is genuinely still.
|
||||||
|
*/
|
||||||
|
private val settleSignal: Flow<VeilSettleState> =
|
||||||
|
combine(uiState, artTracker.inFlight) { state, artInFlight ->
|
||||||
|
VeilSettleState(
|
||||||
|
contentKey = state,
|
||||||
|
hasContent = state is UiState.Success,
|
||||||
|
quiescent = artInFlight == 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val veil = UpdateVeilController(
|
||||||
|
scope = viewModelScope,
|
||||||
|
settleSignal = settleSignal,
|
||||||
|
shouldVeil = {
|
||||||
|
// Only worth hiding churn when there's already content to
|
||||||
|
// hide. A cold load over an empty cache keeps its skeleton —
|
||||||
|
// veiling that would replace a useful affordance with an
|
||||||
|
// opaque panel. `hasCachedIndex` is the honest check: uiState
|
||||||
|
// still reads Loading until the screen subscribes, so on a
|
||||||
|
// process restore over a warm cache it would say "no content"
|
||||||
|
// right before the cache emits.
|
||||||
|
uiState.value is UiState.Success || homeRepository.hasCachedIndex()
|
||||||
|
},
|
||||||
|
onSessionEnd = ::reportRefreshOutcome,
|
||||||
|
work = ::runRefresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the user how a refresh *they asked for* went, in the one case
|
||||||
|
* the veil can't: when nothing changed there's no veil to see, and a
|
||||||
|
* pull that produces no visible response at all reads as broken.
|
||||||
|
*
|
||||||
|
* Only user-initiated sessions say anything. The same outcome from a
|
||||||
|
* background check — the initial load, the 03:00 rebuild, a reconnect —
|
||||||
|
* is noise, and "Already up to date" on every launch would be worse
|
||||||
|
* than silence (operator's call, 2026-07-31).
|
||||||
|
*/
|
||||||
|
private fun reportRefreshOutcome(result: VeilSessionResult) {
|
||||||
|
if (!result.userInitiated) return
|
||||||
|
when (result.outcome) {
|
||||||
|
// The veil was the feedback.
|
||||||
|
VeilOutcome.CHANGED -> return
|
||||||
|
VeilOutcome.UNCHANGED -> snackbarMessages.trySend("Already up to date")
|
||||||
|
VeilOutcome.FAILED -> snackbarMessages.trySend("Couldn't check for updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the "Updating your mixes…" veil should be raised.
|
||||||
|
*
|
||||||
|
* Raised only when a refresh actually changes what's on screen, and then
|
||||||
|
* held until Home settles — tiles hydrated, artwork loaded — instead of
|
||||||
|
* for a fixed delay after the network pull returns (issue #2327). A
|
||||||
|
* refresh that returns what's already cached shows no veil at all;
|
||||||
|
* [reportRefreshOutcome] tells the user instead, if they asked.
|
||||||
|
*/
|
||||||
|
val isUpdating: StateFlow<Boolean> = veil.visible
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Every refresh path goes through the controller, which decides
|
||||||
|
// per session whether to raise the veil. That includes the initial
|
||||||
|
// load: over a warm cache it's a full re-pull that churns every
|
||||||
|
// section, and it used to run completely unveiled.
|
||||||
|
veil.request()
|
||||||
|
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
||||||
|
// load while the server was unreachable re-pulls itself the moment
|
||||||
|
// health returns — same idiom as SyncController, one layer up.
|
||||||
|
// This is also the recovery that keeps trying after the veil has
|
||||||
|
// given up and lowered; the controller sets no latch against it.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { veil.request() }
|
||||||
|
}
|
||||||
|
// Server-side changes that rewrite what Home renders (#968 and
|
||||||
|
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the
|
||||||
|
// web SSE consumer.
|
||||||
|
viewModelScope.launch {
|
||||||
|
eventsStream.events
|
||||||
|
.filter { it.kind in VEILED_EVENT_KINDS }
|
||||||
|
.collect { veil.request() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Events that change what Home shows. `playlist.system_rebuilt`
|
||||||
|
* is the 03:00 daily rebuild; the other `playlist.*` kinds move
|
||||||
|
* the Playlists and Songs-like rows; `scan.run_finished` changes
|
||||||
|
* Recently added (and Home never reacted to it at all before).
|
||||||
|
*/
|
||||||
|
private val VEILED_EVENT_KINDS = setOf(
|
||||||
|
"playlist.system_rebuilt",
|
||||||
|
"playlist.created",
|
||||||
|
"playlist.updated",
|
||||||
|
"playlist.deleted",
|
||||||
|
"playlist.tracks_changed",
|
||||||
|
"scan.run_finished",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Screen ──────────────────────────────────────────────────────────
|
// ─── Screen ──────────────────────────────────────────────────────────
|
||||||
@@ -367,46 +577,114 @@ fun HomeScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
|
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refreshFromPull() },
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
// Key Crossfade on the state CLASS, not the instance. Each
|
Box(Modifier.fillMaxSize()) {
|
||||||
// section emission produces a new UiState.Success(data); if
|
// Every cover below reports its load state to the tracker,
|
||||||
// we keyed on `state` directly, every per-section
|
// so the veil can wait for artwork instead of guessing.
|
||||||
// hydration tick would re-run the 300ms crossfade, and
|
CompositionLocalProvider(
|
||||||
// first-sign-in (six sections cascading in) reads as
|
LocalArtSettleTracker provides viewModel.artTracker,
|
||||||
// continuous flicker. Keying on the class restricts the
|
) {
|
||||||
// animation to Loading↔Success↔Empty↔Error transitions and
|
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||||
// lets normal Success→Success recompositions update the
|
|
||||||
// LazyColumn without a fade.
|
|
||||||
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
|
||||||
when (val s = state) {
|
|
||||||
UiState.Loading -> HomeSkeletonContent()
|
|
||||||
UiState.Empty -> EmptyState(
|
|
||||||
title = "Welcome to Minstrel",
|
|
||||||
body = "Nothing to show yet — scan a folder in your server " +
|
|
||||||
"settings, then come back here for system playlists " +
|
|
||||||
"and recommendations.",
|
|
||||||
)
|
|
||||||
is UiState.Error -> EmptyState(
|
|
||||||
title = "Couldn't load home",
|
|
||||||
body = s.message,
|
|
||||||
)
|
|
||||||
is UiState.Success -> HomeSuccessContent(
|
|
||||||
sections = s.data,
|
|
||||||
systemStatus = systemStatus,
|
|
||||||
offline = offline,
|
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
|
||||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
|
||||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
|
||||||
onMostPlayedTap = viewModel::playMostPlayed,
|
|
||||||
onPlayPool = viewModel::playPool,
|
|
||||||
onPlayAlbum = viewModel::playAlbum,
|
|
||||||
onPlayArtist = viewModel::playArtistShuffled,
|
|
||||||
onPlayPlaylist = viewModel::playPlaylist,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
// Refresh veil: rebuild / reconnect / pull / event churn all
|
||||||
|
// hide behind an "Updating your mixes…" wipe that stays up
|
||||||
|
// until the screen has actually stopped moving.
|
||||||
|
UpdatingVeil(visible = updating)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
|
||||||
|
*
|
||||||
|
* Key the Crossfade on the state CLASS, not the instance. Each section
|
||||||
|
* emission produces a new UiState.Success(data); if we keyed on `state`
|
||||||
|
* directly, every per-section hydration tick would re-run the 300ms
|
||||||
|
* crossfade, and first-sign-in (six sections cascading in) reads as
|
||||||
|
* continuous flicker. Keying on the class restricts the animation to
|
||||||
|
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
|
||||||
|
* recompositions update the LazyColumn without a fade.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun HomeStateCrossfade(
|
||||||
|
state: UiState<HomeSections>,
|
||||||
|
systemStatus: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: HomeViewModel,
|
||||||
|
) {
|
||||||
|
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
||||||
|
when (val s = state) {
|
||||||
|
UiState.Loading -> HomeSkeletonContent()
|
||||||
|
UiState.Empty -> EmptyState(
|
||||||
|
title = "Welcome to Minstrel",
|
||||||
|
body = "Nothing to show yet — scan a folder in your server " +
|
||||||
|
"settings, then come back here for system playlists " +
|
||||||
|
"and recommendations.",
|
||||||
|
)
|
||||||
|
is UiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load home",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.retry() },
|
||||||
|
)
|
||||||
|
is UiState.Success -> HomeSuccessContent(
|
||||||
|
sections = s.data,
|
||||||
|
systemStatus = systemStatus,
|
||||||
|
offline = offline,
|
||||||
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||||
|
onMostPlayedTap = viewModel::playMostPlayed,
|
||||||
|
onPlayPool = viewModel::playPool,
|
||||||
|
onPlayAlbum = viewModel::playAlbum,
|
||||||
|
onPlayArtist = viewModel::playArtistShuffled,
|
||||||
|
onPlayPlaylist = viewModel::playPlaylist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-bleed "Updating your mixes…" veil that wipes in from the left,
|
||||||
|
* holds while an automatic refresh repopulates Home, then wipes off.
|
||||||
|
* Near-opaque so the section churn underneath never shows; swallows taps
|
||||||
|
* while raised so a mid-hydration tile can't be hit.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.UpdatingVeil(visible: Boolean) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
|
||||||
|
exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) {},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(VEIL_SPINNER_DP.dp),
|
||||||
|
strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
|
||||||
|
Text(
|
||||||
|
text = "Updating your mixes…",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,10 +754,12 @@ private fun HomeSuccessContent(
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
// Always rendered: real system/user playlists, with
|
// Always rendered: real system/user playlists, with
|
||||||
// placeholder cards filling the For You / Discover /
|
// placeholder cards filling the For You / Discover slots
|
||||||
// 3× Songs-like slots that haven't generated yet. When
|
// that haven't generated yet. When offline, the cache-backed
|
||||||
// offline, the cache-backed pool cards lead the row.
|
// pool cards lead the row. Songs-like now lives in its own
|
||||||
|
// dedicated row below (#1491), no longer inside this carousel.
|
||||||
PlaylistsRow(
|
PlaylistsRow(
|
||||||
|
title = "Playlists",
|
||||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||||
offline = offline,
|
offline = offline,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
@@ -487,6 +767,18 @@ private fun HomeSuccessContent(
|
|||||||
onPlayPlaylist = onPlayPlaylist,
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Songs-like is the best-performing surface (#1491) — promoted out
|
||||||
|
// of the Playlists carousel into its own row so it shows a wider
|
||||||
|
// spread of "Songs like {artist}" mixes. Hidden when there's
|
||||||
|
// nothing to show (offline with none cached).
|
||||||
|
songsLikeSection(
|
||||||
|
playlists = sections.playlists,
|
||||||
|
status = systemStatus,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
youMightLikeSection(
|
youMightLikeSection(
|
||||||
albums = sections.youMightLikeAlbums,
|
albums = sections.youMightLikeAlbums,
|
||||||
artists = sections.youMightLikeArtists,
|
artists = sections.youMightLikeArtists,
|
||||||
@@ -510,6 +802,35 @@ private fun HomeSuccessContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
|
||||||
|
* rendering with a distinct title; hidden entirely when there's nothing
|
||||||
|
* to show (offline with no cached mixes). Online with none generated yet
|
||||||
|
* still shows a few placeholders so the building / seed-needed state is
|
||||||
|
* visible, matching the pre-promotion carousel behavior.
|
||||||
|
*/
|
||||||
|
private fun LazyListScope.songsLikeSection(
|
||||||
|
playlists: List<PlaylistRef>,
|
||||||
|
status: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
onPlaylistClick: (String) -> Unit,
|
||||||
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
|
) {
|
||||||
|
val rowItems = buildSongsLikeRow(playlists, status, offline)
|
||||||
|
if (rowItems.isEmpty()) return
|
||||||
|
item {
|
||||||
|
PlaylistsRow(
|
||||||
|
title = "Songs like…",
|
||||||
|
rowItems = rowItems,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun LazyListScope.recentlyAddedSection(
|
private fun LazyListScope.recentlyAddedSection(
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
albums: List<HomeTile<AlbumRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
@@ -779,13 +1100,14 @@ private fun AlbumsRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaylistsRow(
|
private fun PlaylistsRow(
|
||||||
|
title: String,
|
||||||
rowItems: List<PlaylistRowItem>,
|
rowItems: List<PlaylistRowItem>,
|
||||||
offline: Boolean,
|
offline: Boolean,
|
||||||
onPlaylistClick: (String) -> Unit,
|
onPlaylistClick: (String) -> Unit,
|
||||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
HorizontalScrollRow(title = "Playlists") {
|
HorizontalScrollRow(title = title) {
|
||||||
itemsIndexed(items = rowItems) { _, item ->
|
itemsIndexed(items = rowItems) { _, item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
||||||
@@ -836,11 +1158,12 @@ enum class OfflinePoolKind(val label: String) {
|
|||||||
/**
|
/**
|
||||||
* Builds the Home Playlists row.
|
* Builds the Home Playlists row.
|
||||||
*
|
*
|
||||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
* Online: For You + Discover fixed slots (real card when generated,
|
||||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
* rediscover / new for you / on this day / first listens) when they exist —
|
||||||
* no placeholders for these since they're conditional on library shape — then
|
* no placeholders for these since they're conditional on library shape — then
|
||||||
* user-owned playlists.
|
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
|
||||||
|
* [buildSongsLikeRow] and no longer appears in this carousel.
|
||||||
*
|
*
|
||||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||||
* same real playlists in curated order but stably partitioned fully-cached
|
* same real playlists in curated order but stably partitioned fully-cached
|
||||||
@@ -867,7 +1190,7 @@ internal fun buildPlaylistsRow(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
||||||
private fun buildOnlineRow(
|
private fun buildOnlineRow(
|
||||||
owned: List<PlaylistRef>,
|
owned: List<PlaylistRef>,
|
||||||
status: SystemPlaylistsStatus,
|
status: SystemPlaylistsStatus,
|
||||||
@@ -879,12 +1202,7 @@ private fun buildOnlineRow(
|
|||||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
||||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
|
||||||
out += songsLike.getOrNull(i)
|
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
|
||||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
|
||||||
}
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||||
}
|
}
|
||||||
@@ -892,16 +1210,44 @@ private fun buildOnlineRow(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
||||||
|
* mixes the server generated, no longer capped to the 3 carousel slots.
|
||||||
|
*
|
||||||
|
* Online: every generated mix as a real card; when none exist yet, a few
|
||||||
|
* placeholders so the building / seed-needed state stays visible.
|
||||||
|
* Offline: the cached mixes only (fully-cached first, greyed after), and
|
||||||
|
* an empty list — hiding the whole section — when nothing is cached.
|
||||||
|
*/
|
||||||
|
internal fun buildSongsLikeRow(
|
||||||
|
owned: List<PlaylistRef>,
|
||||||
|
status: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
): List<PlaylistRowItem> {
|
||||||
|
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
|
||||||
|
return when {
|
||||||
|
offline -> {
|
||||||
|
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||||
|
(available + greyed).map { PlaylistRowItem.Real(it) }
|
||||||
|
}
|
||||||
|
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
|
||||||
|
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
|
||||||
|
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||||
* only differs by dropping placeholders + partitioning available-first.
|
* only differs by dropping placeholders + partitioning available-first.
|
||||||
|
* Songs-like is excluded here too — it renders in its own row via
|
||||||
|
* [buildSongsLikeRow] in both online and offline modes (#1491).
|
||||||
*/
|
*/
|
||||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||||
val out = mutableListOf<PlaylistRef>()
|
val out = mutableListOf<PlaylistRef>()
|
||||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||||
}
|
}
|
||||||
@@ -916,7 +1262,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
|||||||
else -> "pending"
|
else -> "pending"
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SONGS_LIKE_SLOTS = 3
|
// How many "Songs like…" placeholder cards the dedicated row shows while
|
||||||
|
// the mixes haven't generated yet (building / seed-needed). Real mixes,
|
||||||
|
// once generated, are shown in full and no longer capped by this (#1491).
|
||||||
|
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 5 system playlist kinds the server generates that aren't pinned
|
* The 5 system playlist kinds the server generates that aren't pinned
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
@@ -116,9 +116,10 @@ private fun AlbumDetailStateContent(
|
|||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is AlbumDetailUiState.Loading ->
|
is AlbumDetailUiState.Loading ->
|
||||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||||
is AlbumDetailUiState.Error -> EmptyState(
|
is AlbumDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load album",
|
title = "Couldn't load album",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AlbumDetailUiState.Success -> {
|
is AlbumDetailUiState.Success -> {
|
||||||
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||||
@@ -36,6 +38,7 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -64,6 +67,14 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||||
|
// load while the server was unreachable re-fetches when health
|
||||||
|
// returns, so coming back to the screen shows content, not an error.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AlbumDetailUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
|
|||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -107,9 +107,10 @@ fun ArtistDetailScreen(
|
|||||||
} else {
|
} else {
|
||||||
SkeletonArtistAlbumsGrid()
|
SkeletonArtistAlbumsGrid()
|
||||||
}
|
}
|
||||||
is ArtistDetailUiState.Error -> EmptyState(
|
is ArtistDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load artist",
|
title = "Couldn't load artist",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is ArtistDetailUiState.Success ->
|
is ArtistDetailUiState.Success ->
|
||||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||||
|
|||||||
+16
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
@@ -45,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -69,6 +72,19 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245). From Error, redo the
|
||||||
|
// whole load; from Success, re-pull just the secondary sections —
|
||||||
|
// their fetch failures are swallowed to emptyList, so a dead zone
|
||||||
|
// during the first load leaves them permanently absent otherwise.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
when (internal.value) {
|
||||||
|
is ArtistDetailUiState.Error -> refresh()
|
||||||
|
is ArtistDetailUiState.Success -> loadSecondarySections()
|
||||||
|
is ArtistDetailUiState.Loading -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleLikeArtist() {
|
fun toggleLikeArtist() {
|
||||||
|
|||||||
@@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.observeArtists(),
|
repository.observeArtists(),
|
||||||
repository.observeAlbums(),
|
repository.observeAlbums(),
|
||||||
) { artists, albums ->
|
syncController.lastSyncError,
|
||||||
if (artists.isEmpty() && albums.isEmpty()) {
|
) { artists, albums, syncError ->
|
||||||
UiState.Empty
|
when {
|
||||||
} else {
|
artists.isNotEmpty() || albums.isNotEmpty() ->
|
||||||
UiState.Success(LibraryData(artists, albums))
|
UiState.Success(LibraryData(artists, albums))
|
||||||
|
// Empty cache + failed sync is a load failure, not an empty
|
||||||
|
// library — surface it so the user gets a Retry instead of
|
||||||
|
// the welcome copy. Heals automatically: SyncController
|
||||||
|
// re-syncs on health recovery, clearing lastSyncError.
|
||||||
|
syncError != null -> UiState.Error(syncError)
|
||||||
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||||
@@ -38,6 +41,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -46,6 +50,7 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
|||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -68,14 +73,25 @@ data class LikedSections(
|
|||||||
class LikedTabViewModel @Inject constructor(
|
class LikedTabViewModel @Inject constructor(
|
||||||
private val repository: LikesRepository,
|
private val repository: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Copy for the most recent refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshIds() }
|
runCatching { repository.refreshIds() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,9 +108,16 @@ class LikedTabViewModel @Inject constructor(
|
|||||||
repository.observeLikedArtists(),
|
repository.observeLikedArtists(),
|
||||||
repository.observeLikedAlbums(),
|
repository.observeLikedAlbums(),
|
||||||
repository.observeLikedTracks(),
|
repository.observeLikedTracks(),
|
||||||
) { artists, albums, tracks ->
|
refreshError,
|
||||||
|
) { artists, albums, tracks, err ->
|
||||||
val sections = LikedSections(artists, albums, tracks)
|
val sections = LikedSections(artists, albums, tracks)
|
||||||
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
|
when {
|
||||||
|
!sections.isAllEmpty -> UiState.Success(sections)
|
||||||
|
// Empty cache + failed refresh is a load failure, not "no
|
||||||
|
// likes yet" — surface Error so the user gets a Retry.
|
||||||
|
err != null -> UiState.Error(err)
|
||||||
|
else -> UiState.Empty
|
||||||
|
}
|
||||||
}.asCacheFirstStateFlow(viewModelScope)
|
}.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,9 +153,10 @@ fun LikedTab(
|
|||||||
body = "Tap the heart on an artist, album, or track to start " +
|
body = "Tap the heart on an artist, album, or track to start " +
|
||||||
"building your liked collection.",
|
"building your liked collection.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load likes",
|
title = "Couldn't load likes",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> LikedContent(
|
is UiState.Success -> LikedContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
|
||||||
|
data class AdminTagSourceRef(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val requiresApiKey: Boolean,
|
||||||
|
val supports: List<String>,
|
||||||
|
val enabled: Boolean,
|
||||||
|
val apiKeySet: Boolean,
|
||||||
|
val testable: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Result of a provider "test connection" call. */
|
||||||
|
data class TagSourceTestResult(
|
||||||
|
val ok: Boolean,
|
||||||
|
val durationMs: Long = 0,
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.fabledsword.minstrel.models
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kind of Lidarr request being created. Wire form is the lowercase
|
* Kind of Lidarr request being created. Wire form is the lowercase
|
||||||
* enum name; the helper [wire] keeps that mapping in one place.
|
* enum name; the helper [wire] keeps that mapping in one place.
|
||||||
@@ -49,6 +52,8 @@ data class ArtistSuggestionRef(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val imageUrl: String = "",
|
val imageUrl: String = "",
|
||||||
val attribution: List<SeedContributionRef> = emptyList(),
|
val attribution: List<SeedContributionRef> = emptyList(),
|
||||||
|
/** Taste-profile tags this candidate matches, strongest first (#2377). */
|
||||||
|
val matchedTags: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val attributionText: String
|
val attributionText: String
|
||||||
get() {
|
get() {
|
||||||
@@ -63,7 +68,92 @@ data class ArtistSuggestionRef(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subtitle line for the card.
|
||||||
|
*
|
||||||
|
* Prefers the taste-tag reason over seed attribution when we have one,
|
||||||
|
* because it describes the MUSIC ("sounds like what you like") rather than
|
||||||
|
* the graph ("adjacent to something you played") — the whole point of
|
||||||
|
* milestone #268 slice 6. Falls back to attribution, which is the common
|
||||||
|
* case: tag coverage for out-of-library artists is partial by nature
|
||||||
|
* (#2376), so most candidates have no matched tags.
|
||||||
|
*
|
||||||
|
* Kept in lockstep with the web client's reasonText() in
|
||||||
|
* SuggestionFeed.svelte — same wording, same Oxford comma.
|
||||||
|
*/
|
||||||
|
val reasonText: String
|
||||||
|
get() {
|
||||||
|
val tags = matchedTags.take(MAX_ATTRIBUTION_PHRASES)
|
||||||
|
return when (tags.size) {
|
||||||
|
0 -> attributionText
|
||||||
|
1 -> "Matches your taste in ${tags[0]}."
|
||||||
|
2 -> "Matches your taste in ${tags[0]} and ${tags[1]}."
|
||||||
|
else -> "Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_ATTRIBUTION_PHRASES = 3
|
private const val MAX_ATTRIBUTION_PHRASES = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A suggestion the user parked with "not right now" (#2374).
|
||||||
|
*
|
||||||
|
* Deliberately NOT a dislike: it carries no verdict on the artist, expires on
|
||||||
|
* its own, and never reaches the taste profile. Anything that treats this as
|
||||||
|
* negative preference signal is a bug.
|
||||||
|
*
|
||||||
|
* [snoozedUntil] is the raw RFC3339 string from the wire. Only the server
|
||||||
|
* decides whether a snooze is still in effect — every row the client receives
|
||||||
|
* already is — so this is read purely to phrase "back in about 3 months".
|
||||||
|
*/
|
||||||
|
data class SuggestionSnoozeRef(
|
||||||
|
val mbid: String,
|
||||||
|
val name: String,
|
||||||
|
val snoozedUntil: String,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Relative return phrase for the manage list. Relative rather than a
|
||||||
|
* calendar date because the exact day a 90-day snooze lapses is noise the
|
||||||
|
* user never asked for.
|
||||||
|
*
|
||||||
|
* [nowMs] is injectable so this is testable without freezing the clock.
|
||||||
|
* Returns "shortly" for an unparseable or already-past timestamp: the row
|
||||||
|
* is on screen, so the server still considers it snoozed, and guessing is
|
||||||
|
* better than rendering an empty line.
|
||||||
|
*/
|
||||||
|
fun returnsIn(nowMs: Long = System.currentTimeMillis()): String {
|
||||||
|
val remainingMs = runCatching { Instant.parse(snoozedUntil).toEpochMilliseconds() }
|
||||||
|
.getOrNull()?.minus(nowMs)
|
||||||
|
// Two ways to have nothing to state: an unparseable timestamp, or one
|
||||||
|
// already lapsed by our clock though the server still returned the row
|
||||||
|
// (the two disagree). Neither is "today", which would read as a real
|
||||||
|
// prediction.
|
||||||
|
if (remainingMs == null || remainingMs <= 0) return "shortly"
|
||||||
|
val days = (remainingMs.toDouble() / MILLIS_PER_DAY).roundToInt()
|
||||||
|
return when {
|
||||||
|
days < 1 -> "today"
|
||||||
|
days == 1 -> "tomorrow"
|
||||||
|
days < DAYS_BEFORE_MONTHS -> "in $days days"
|
||||||
|
else -> {
|
||||||
|
val months = (days.toDouble() / DAYS_PER_MONTH).roundToInt()
|
||||||
|
if (months == 1) "in about a month" else "in about $months months"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MILLIS_PER_DAY = 86_400_000.0
|
||||||
|
|
||||||
|
// Below this, days read more naturally than a rounded month count.
|
||||||
|
//
|
||||||
|
// Must be <= DAYS_PER_MONTH, or the singular "in about a month" is
|
||||||
|
// unreachable: a rounded month count of 1 needs 15..44 days, and any
|
||||||
|
// threshold above 30 sends all of those down the days branch instead.
|
||||||
|
// This was 45 and the singular branch was dead code — the unit test
|
||||||
|
// for it is what surfaced that.
|
||||||
|
const val DAYS_BEFORE_MONTHS = 30
|
||||||
|
const val DAYS_PER_MONTH = 30.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
|
||||||
|
* Every field defaults defensively so a missing JSON key never crashes
|
||||||
|
* deserialization. `versionBumped` is present only on the PATCH response.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AdminTagSourceWire(
|
||||||
|
val id: String = "",
|
||||||
|
@SerialName("display_name") val displayName: String = "",
|
||||||
|
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
|
||||||
|
val supports: List<String> = emptyList(),
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
@SerialName("api_key_set") val apiKeySet: Boolean = false,
|
||||||
|
@SerialName("display_order") val displayOrder: Int = 0,
|
||||||
|
val testable: Boolean = false,
|
||||||
|
@SerialName("version_bumped") val versionBumped: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminTagSourcesListWire(
|
||||||
|
val providers: List<AdminTagSourceWire> = emptyList(),
|
||||||
|
@SerialName("sources_version") val sourcesVersion: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TestTagSourceWire(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
@SerialName("duration_ms") val durationMs: Long = 0,
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
@@ -35,6 +35,13 @@ data class ArtistSuggestionWire(
|
|||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("image_url") val imageUrl: String = "",
|
@SerialName("image_url") val imageUrl: String = "",
|
||||||
val attribution: List<SeedContributionWire> = emptyList(),
|
val attribution: List<SeedContributionWire> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Tags this candidate shares with the user's taste profile, strongest
|
||||||
|
* first (max 3, #2377). Absent for most candidates — tag coverage for
|
||||||
|
* out-of-library artists is partial by nature (#2376) — so the default
|
||||||
|
* empty list is the common case, not an error.
|
||||||
|
*/
|
||||||
|
@SerialName("matched_tags") val matchedTags: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +55,36 @@ data class SeedContributionWire(
|
|||||||
@SerialName("is_liked") val isLiked: Boolean = false,
|
@SerialName("is_liked") val isLiked: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /api/discover/snoozes` — a suggestion the user parked
|
||||||
|
* with "not right now". The server only returns rows that are still in
|
||||||
|
* effect, so the client never compares [snoozedUntil] against the clock to
|
||||||
|
* decide whether to show it; it reads it only to say when the artist comes
|
||||||
|
* back.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SuggestionSnoozeWire(
|
||||||
|
val mbid: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
@SerialName("snoozed_until") val snoozedUntil: String = "",
|
||||||
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `POST /api/discover/suggestions/{mbid}/snooze`.
|
||||||
|
*
|
||||||
|
* [name] is required by the server, not decorative: suggestions are
|
||||||
|
* out-of-library, so there is no artists row to resolve a display name from
|
||||||
|
* and the snooze list would have nothing to render. Omitting it is a 400.
|
||||||
|
*
|
||||||
|
* No `days` field. The duration is the server's to own (90 days); pinning it
|
||||||
|
* client-side would freeze the default at whatever this build shipped.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SnoozeSuggestionBody(
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
||||||
* payload shape. Optional fields are emitted only when non-null
|
* payload shape. Optional fields are emitted only when non-null
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
|
|||||||
@SerialName("track_id") val trackId: String,
|
@SerialName("track_id") val trackId: String,
|
||||||
@SerialName("client_id") val clientId: String,
|
@SerialName("client_id") val clientId: String,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning (server normalizes).
|
||||||
|
@SerialName("device_class") val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
|
|||||||
val at: String,
|
val at: String,
|
||||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning (server normalizes).
|
||||||
|
@SerialName("device_class") val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
|
|||||||
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
||||||
|
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
||||||
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
composable<AdminTagSources> {
|
||||||
|
WithAnimatedScope {
|
||||||
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
|
AdminTagSourcesScreen(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable data object AdminRequests
|
@Serializable data object AdminRequests
|
||||||
@Serializable data object AdminQuarantine
|
@Serializable data object AdminQuarantine
|
||||||
@Serializable data object AdminUsers
|
@Serializable data object AdminUsers
|
||||||
|
@Serializable data object AdminTagSources
|
||||||
|
|
||||||
// ── Outside-shell full-screen routes ──────────────────────────────────
|
// ── Outside-shell full-screen routes ──────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
+106
-24
@@ -17,6 +17,7 @@ import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -58,8 +59,10 @@ import timber.log.Timber
|
|||||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||||
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
* transport at x-rincon-queue:<udn>#0. Skip/prev/seekTo delegate to
|
||||||
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
* AVTransport Next/Previous/SeekToTrack so Sonos manages gap-free
|
||||||
* advance natively. PollLoop syncs the local cursor by comparing the
|
* advance natively. PollLoop keeps the local cursor aligned to the
|
||||||
* 1-based Track index from GetPositionInfo.
|
* track the renderer is actually playing, matched by track identity
|
||||||
|
* (the id in GetPositionInfo's TrackURI) so it survives queue-reload
|
||||||
|
* index drift -- see [syncLocalCursorToRemote].
|
||||||
*/
|
*/
|
||||||
class MinstrelForwardingPlayer(
|
class MinstrelForwardingPlayer(
|
||||||
private val delegate: Player,
|
private val delegate: Player,
|
||||||
@@ -339,6 +342,23 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The system media notification / lock-screen / Android Auto / Wear 'next'
|
||||||
|
// and 'previous' buttons issue COMMAND_SEEK_TO_NEXT / COMMAND_SEEK_TO_PREVIOUS
|
||||||
|
// -> Player.seekToNext() / seekToPrevious(), which are DISTINCT from the
|
||||||
|
// *MediaItem variants the in-app transport buttons call. Un-overridden, the
|
||||||
|
// base ForwardingPlayer forwards these to the paused local delegate -- so
|
||||||
|
// the notification next/prev only nudged the local cursor (which the poll
|
||||||
|
// then re-synced back to Sonos), reading as dead buttons while casting.
|
||||||
|
// Route them through the same Sonos path as the media-item variants when a
|
||||||
|
// UPnP route is engaged; plain local playback keeps the default behaviour.
|
||||||
|
override fun seekToNext() {
|
||||||
|
if (isRemote() || isLoadingUpnp()) seekToNextMediaItem() else super.seekToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun seekToPrevious() {
|
||||||
|
if (isRemote() || isLoadingUpnp()) seekToPreviousMediaItem() else super.seekToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
override fun getCurrentPosition(): Long =
|
override fun getCurrentPosition(): Long =
|
||||||
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
if (isRemote()) remoteState.positionMs else super.getCurrentPosition()
|
||||||
|
|
||||||
@@ -526,18 +546,8 @@ class MinstrelForwardingPlayer(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* One poll tick: read position + transport state from Sonos, apply to
|
* One poll tick: read position + transport state from Sonos, apply to
|
||||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
* [remoteState], and sync the local cursor to the track the renderer is
|
||||||
* index when not in queue load.
|
* actually playing (see [syncLocalCursorToRemote]).
|
||||||
*
|
|
||||||
* Cursor sync is gated on `holder.target == null` (= not loading)
|
|
||||||
* because during load Sonos reports Track=1 while we're still
|
|
||||||
* appending, and syncing would race the SetAV+Seek that lands
|
|
||||||
* after. Outside load, forward sync catches Sonos auto-advances
|
|
||||||
* (queue end-of-track), Sonos-app driven Next presses, and any
|
|
||||||
* drift after a brief poll-failure burst that didn't trip the
|
|
||||||
* drop threshold. Forward-only because a Next override we just
|
|
||||||
* issued can race with a poll still reporting the prior Track --
|
|
||||||
* the next poll catches up safely.
|
|
||||||
*/
|
*/
|
||||||
private suspend fun pollOnce(active: ActiveUpnp) {
|
private suspend fun pollOnce(active: ActiveUpnp) {
|
||||||
val info = active.avTransport.getPositionInfo()
|
val info = active.avTransport.getPositionInfo()
|
||||||
@@ -553,7 +563,7 @@ class MinstrelForwardingPlayer(
|
|||||||
trackUri = info.trackUri,
|
trackUri = info.trackUri,
|
||||||
trackNumber = info.track,
|
trackNumber = info.track,
|
||||||
)
|
)
|
||||||
maybeSyncLocalCursor(info.track)
|
syncLocalCursorToRemote(sonosTrack = info.track, trackUri = info.trackUri)
|
||||||
val transport = active.avTransport.getTransportInfo()
|
val transport = active.avTransport.getTransportInfo()
|
||||||
when (transport.state) {
|
when (transport.state) {
|
||||||
TransportState.PLAYING -> {
|
TransportState.PLAYING -> {
|
||||||
@@ -577,23 +587,95 @@ class MinstrelForwardingPlayer(
|
|||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun maybeSyncLocalCursor(sonosTrack: Int) {
|
/**
|
||||||
if (holder.target.value != null) return
|
* Align the paused local delegate cursor to the track the renderer is
|
||||||
if (sonosTrack <= 0) return
|
* actually playing, so the un-overridden current-item getters
|
||||||
val sonosIdx = sonosTrack - 1
|
* (getCurrentMediaItem/Index -- what both the in-app player UI via
|
||||||
|
* PlayerController.onEvents AND the MediaSession notification read) always
|
||||||
|
* resolve to that track. This is THE single source of truth for "what's
|
||||||
|
* playing" during a cast.
|
||||||
|
*
|
||||||
|
* Identity-first: match the track-id embedded in the renderer's current URI
|
||||||
|
* against the delegate's MediaItem.mediaId (PlayerController sets mediaId =
|
||||||
|
* TrackRef.id), so the index-offset wobble across queue reloads / re-casts
|
||||||
|
* can never park the cursor on a stale (pre-cast) track. Falls back to the
|
||||||
|
* 1-based Sonos Track index only when the URI can't be resolved to a queue
|
||||||
|
* item. Unlike the old forward-only sync this moves the cursor in BOTH
|
||||||
|
* directions -- a renderer Previous / re-cast to a lower track pulls it back
|
||||||
|
* too. Runs on the application looper (delegate access contract).
|
||||||
|
*
|
||||||
|
* Suppressed while:
|
||||||
|
* - loading ([isLoadingUpnp]) -- the native Sonos queue is still being
|
||||||
|
* (re)uploaded, so its Track index is meaningless; and
|
||||||
|
* - a user transport (next/prev/seekTo idx) is pending Sonos's ack --
|
||||||
|
* the override already advanced the delegate optimistically, and Sonos
|
||||||
|
* still reports the OLD track for a beat, so syncing now would undo the
|
||||||
|
* user's press. The pending deadline is honoured directly so a missed
|
||||||
|
* clear can't wedge the sync.
|
||||||
|
*/
|
||||||
|
private fun syncLocalCursorToRemote(sonosTrack: Int, trackUri: String) {
|
||||||
|
if (isLoadingUpnp()) return
|
||||||
|
val pendingDeadline = remoteState.pendingTransportDeadlineMs
|
||||||
|
if (pendingDeadline > 0L && SystemClock.elapsedRealtime() < pendingDeadline) return
|
||||||
handler.post {
|
handler.post {
|
||||||
val localIdx = delegate.currentMediaItemIndex
|
val target = resolveRemoteCursorIndex(sonosTrack, trackUri) ?: return@post
|
||||||
if (sonosIdx > localIdx && sonosIdx < delegate.mediaItemCount) {
|
if (target != delegate.currentMediaItemIndex) {
|
||||||
Timber.w(
|
Timber.w(
|
||||||
"UPnP cursor catch-up: local=%d -> sonos=%d",
|
"UPnP cursor sync: local=%d -> %d",
|
||||||
localIdx, sonosIdx,
|
delegate.currentMediaItemIndex, target,
|
||||||
)
|
)
|
||||||
delegate.seekTo(sonosIdx, 0L)
|
delegate.seekTo(target, 0L)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most-authoritative -> most-degraded (project rule #48): the track-id
|
||||||
|
* parsed from the renderer's current URI wins; else the 1-based Sonos Track
|
||||||
|
* index; else null (leave the cursor put rather than surface a wrong track).
|
||||||
|
* Must run on the application looper -- reads the delegate timeline.
|
||||||
|
*/
|
||||||
|
private fun resolveRemoteCursorIndex(sonosTrack: Int, trackUri: String): Int? {
|
||||||
|
val byId = trackIdFromStreamUri(trackUri)?.let { id ->
|
||||||
|
nearestIndexWithMediaId(id, preferNear = sonosTrack - 1)
|
||||||
|
}
|
||||||
|
return byId ?: (sonosTrack - 1).takeIf { it in 0 until delegate.mediaItemCount }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index of the delegate MediaItem whose mediaId == [mediaId], preferring the
|
||||||
|
* occurrence nearest [preferNear] so a track that appears twice in the queue
|
||||||
|
* doesn't snap the cursor across the queue. null if the id isn't present.
|
||||||
|
*/
|
||||||
|
private fun nearestIndexWithMediaId(mediaId: String, preferNear: Int): Int? {
|
||||||
|
var best: Int? = null
|
||||||
|
var bestDist = Int.MAX_VALUE
|
||||||
|
for (i in 0 until delegate.mediaItemCount) {
|
||||||
|
if (delegate.getMediaItemAt(i).mediaId == mediaId) {
|
||||||
|
val dist = abs(i - preferNear)
|
||||||
|
if (dist < bestDist) {
|
||||||
|
best = i
|
||||||
|
bestDist = dist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract `{id}` from a `.../api/tracks/{id}/stream...` stream URI. */
|
||||||
|
private fun trackIdFromStreamUri(uri: String): String? {
|
||||||
|
val start = uri.indexOf(TRACKS_PATH_MARKER)
|
||||||
|
if (start < 0) return null
|
||||||
|
val idStart = start + TRACKS_PATH_MARKER.length
|
||||||
|
val idEnd = uri.indexOf('/', idStart)
|
||||||
|
return if (idEnd > idStart) uri.substring(idStart, idEnd) else null
|
||||||
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
// Path segment that precedes the track-id in a stream URI
|
||||||
|
// (…/api/tracks/{id}/stream…) — used to map the renderer's current URI
|
||||||
|
// back to a delegate MediaItem by identity.
|
||||||
|
const val TRACKS_PATH_MARKER = "/api/tracks/"
|
||||||
const val POLL_INTERVAL_MS = 1_000L
|
const val POLL_INTERVAL_MS = 1_000L
|
||||||
const val NON_PLAYING_CONFIRM = 2
|
const val NON_PLAYING_CONFIRM = 2
|
||||||
const val SEEK_ACK_WINDOW_MS = 2_000L
|
const val SEEK_ACK_WINDOW_MS = 2_000L
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
clientId = cid,
|
clientId = cid,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
|
deviceClass = DEVICE_CLASS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (curTrackId == trackId) {
|
if (curTrackId == trackId) {
|
||||||
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
atIso = startedAt.toString(),
|
atIso = startedAt.toString(),
|
||||||
durationPlayedMs = durationPlayedMs,
|
durationPlayedMs = durationPlayedMs,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
|
deviceClass = DEVICE_CLASS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
|
|
||||||
// ── Lifecycle: durable-close on app background / detach ────────
|
// ── Lifecycle: durable-close on app background / detach ────────
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
// #1551: Android is a mobile client. Refine to tablet/tv via device
|
||||||
|
// configuration later if the metrics trend view shows it matters.
|
||||||
|
private const val DEVICE_CLASS = "mobile"
|
||||||
|
}
|
||||||
|
|
||||||
override fun onStop(owner: LifecycleOwner) {
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||||
// alone: the foreground media service keeps the process — and this
|
// alone: the foreground media service keeps the process — and this
|
||||||
|
|||||||
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
|
|||||||
controller.addMediaItem(track.toMediaItem(source = null))
|
controller.addMediaItem(track.toMediaItem(source = null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder the queue: move the item at [from] to [to], keeping the domain
|
||||||
|
* snapshot in lock-step with the player's MediaItem timeline. Media3 emits
|
||||||
|
* onEvents → uiState reflects the new order (and the still-playing item's
|
||||||
|
* index). No-op on bad indices or a no-move.
|
||||||
|
*/
|
||||||
|
fun moveInQueue(from: Int, to: Int) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
|
||||||
|
queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
|
||||||
|
controller.moveMediaItem(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the queue item at [index]. When it's the currently-playing item
|
||||||
|
* Media3 advances to the next automatically. No-op on a bad index.
|
||||||
|
*/
|
||||||
|
fun removeFromQueue(index: Int) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (index !in queueRefs.indices) return
|
||||||
|
queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
|
||||||
|
controller.removeMediaItem(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty the queue and stop playback. */
|
||||||
|
fun clearQueue() {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
queueRefs = emptyList()
|
||||||
|
controller.clearMediaItems()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
||||||
* "radio:<id>" so the server-side rotation reporter can
|
* "radio:<id>" so the server-side rotation reporter can
|
||||||
@@ -598,18 +629,19 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One position-polling tick. Owns track-change detection too: when UPnP
|
* One position-polling tick. Patches ONLY the smoothly-changing transport
|
||||||
* is active and the wrapped ExoPlayer is paused, `delegate.seekTo` from
|
* fields (position / duration / play-pause / buffer) via `.copy()`. The
|
||||||
* `maybeSyncLocalCursor` may not fire `onMediaItemTransition`, leaving
|
* queue index + current track are owned solely by [onEvents], which reads
|
||||||
* uiState.queueIndex stuck on the old track even after Sonos has
|
* the authoritative delegate cursor: during a cast,
|
||||||
* advanced. So the tick reads Sonos's reported Track as the source of
|
* [MinstrelForwardingPlayer.syncLocalCursorToRemote] keeps that cursor on
|
||||||
* truth, rebuilds the index/title fields itself, and force-syncs the
|
* the track the renderer is actually playing (matched by identity), so there
|
||||||
* wrapped player as defense in depth.
|
* is exactly one writer of "what's playing" and a stale tick can never
|
||||||
|
* revert it to the old track. (Previously the tick was a second index writer
|
||||||
|
* that fought onEvents — the flicker-to-stale-track bug.)
|
||||||
*/
|
*/
|
||||||
private fun tickPositionPoll(controller: MediaController) {
|
private fun tickPositionPoll(controller: MediaController) {
|
||||||
val upnpActive = activeUpnpHolder.active.value != null
|
val upnpActive = activeUpnpHolder.active.value != null
|
||||||
resolvePendingTransport(controller, upnpActive)
|
resolvePendingTransport(controller, upnpActive)
|
||||||
val pendingTransport = upnpActive && remoteState.pendingTransportDeadlineMs > 0L
|
|
||||||
val effectiveIsPlaying =
|
val effectiveIsPlaying =
|
||||||
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
if (upnpActive) remoteState.isPlaying else controller.isPlaying
|
||||||
val effectivePosition = if (upnpActive) {
|
val effectivePosition = if (upnpActive) {
|
||||||
@@ -617,27 +649,29 @@ class PlayerController @Inject constructor(
|
|||||||
} else {
|
} else {
|
||||||
controller.currentPosition
|
controller.currentPosition
|
||||||
}
|
}
|
||||||
val desiredIdx = desiredQueueIndex(controller, upnpActive)
|
val idx = controller.currentMediaItemIndex
|
||||||
val current = uiStateInternal.value
|
val current = uiStateInternal.value
|
||||||
val newPos = effectivePosition.coerceAtLeast(0)
|
val newPos = effectivePosition.coerceAtLeast(0)
|
||||||
val newDur = effectiveDuration(
|
val newDur = effectiveDuration(
|
||||||
upnpActive,
|
upnpActive,
|
||||||
remoteState.durationMs,
|
remoteState.durationMs,
|
||||||
controller.duration,
|
controller.duration,
|
||||||
desiredIdx = desiredIdx,
|
desiredIdx = idx,
|
||||||
controllerIdx = controller.currentMediaItemIndex,
|
controllerIdx = idx,
|
||||||
)
|
)
|
||||||
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
val newBuf = controller.bufferedPosition.coerceAtLeast(0)
|
||||||
// Track adjustments are forward-only AND suppressed while a user
|
val somethingChanged = current.isPlaying != effectiveIsPlaying ||
|
||||||
// transport press is pending Sonos confirmation. Together those keep
|
current.positionMs != newPos ||
|
||||||
// either direction of user input from being undone by a stale poll.
|
current.durationMs != newDur ||
|
||||||
val trackChanged = !pendingTransport &&
|
current.bufferedPositionMs != newBuf
|
||||||
desiredIdx > current.queueIndex &&
|
if (somethingChanged) {
|
||||||
desiredIdx in queueRefs.indices
|
uiStateInternal.value = current.copy(
|
||||||
publishTickIfChanged(
|
isPlaying = effectiveIsPlaying,
|
||||||
current, trackChanged, desiredIdx,
|
positionMs = newPos,
|
||||||
effectiveIsPlaying, newPos, newDur, newBuf,
|
durationMs = newDur,
|
||||||
)
|
bufferedPositionMs = newBuf,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -654,48 +688,6 @@ class PlayerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Suppress("LongParameterList") // assembled at one tick call site; refactor would cost clarity
|
|
||||||
private fun publishTickIfChanged(
|
|
||||||
current: PlayerUiState,
|
|
||||||
trackChanged: Boolean,
|
|
||||||
desiredIdx: Int,
|
|
||||||
effectiveIsPlaying: Boolean,
|
|
||||||
newPos: Long,
|
|
||||||
newDur: Long,
|
|
||||||
newBuf: Long,
|
|
||||||
) {
|
|
||||||
val somethingChanged = trackChanged ||
|
|
||||||
current.isPlaying != effectiveIsPlaying ||
|
|
||||||
current.positionMs != newPos ||
|
|
||||||
current.durationMs != newDur
|
|
||||||
if (!somethingChanged) return
|
|
||||||
val newTrack = if (trackChanged) queueRefs[desiredIdx] else current.currentTrack
|
|
||||||
val newIdx = if (trackChanged) desiredIdx else current.queueIndex
|
|
||||||
uiStateInternal.value = current.copy(
|
|
||||||
currentTrack = newTrack,
|
|
||||||
queueIndex = newIdx,
|
|
||||||
isPlaying = effectiveIsPlaying,
|
|
||||||
positionMs = newPos,
|
|
||||||
durationMs = newDur,
|
|
||||||
bufferedPositionMs = newBuf,
|
|
||||||
)
|
|
||||||
// Intentionally do NOT call controller.seekTo here. That would route
|
|
||||||
// through MinstrelForwardingPlayer's seekTo override and re-issue
|
|
||||||
// AVTransport.SeekToTrack to Sonos -- which seeks Sonos back to the
|
|
||||||
// start of the same track it's already playing, restarting the song.
|
|
||||||
// The wrapped player's index is kept in sync by maybeSyncLocalCursor's
|
|
||||||
// delegate.seekTo (which bypasses the override). If it lags briefly,
|
|
||||||
// the next pollOnce catches up; the uiState above already reflects
|
|
||||||
// Sonos's truth for the user.
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun desiredQueueIndex(controller: MediaController, upnpActive: Boolean): Int =
|
|
||||||
if (upnpActive) {
|
|
||||||
(remoteState.trackNumber - 1).coerceAtLeast(0)
|
|
||||||
} else {
|
|
||||||
controller.currentMediaItemIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Remote position interpolation state ──────────────────────────────
|
// ── Remote position interpolation state ──────────────────────────────
|
||||||
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
// remoteState.positionMs is only refreshed by ForwardingPlayer's 1Hz
|
||||||
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
// SOAP poll (and only when the round-trip completes -- screen-off WiFi
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
package com.fabledsword.minstrel.player.ui
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
import com.fabledsword.minstrel.player.PlayerUiState
|
import com.fabledsword.minstrel.player.PlayerUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,13 +25,29 @@ import javax.inject.Inject
|
|||||||
* stub-test for ViewModel-level logic when it grows).
|
* stub-test for ViewModel-level logic when it grows).
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
|
||||||
class PlayerViewModel @Inject constructor(
|
class PlayerViewModel @Inject constructor(
|
||||||
private val controller: PlayerController,
|
private val controller: PlayerController,
|
||||||
|
private val likes: LikesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
val uiState: StateFlow<PlayerUiState> = controller.uiState
|
||||||
val dropEvents: SharedFlow<String> = controller.dropEvents
|
val dropEvents: SharedFlow<String> = controller.dropEvents
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive set of liked track ids — the set-based pattern the queue
|
||||||
|
* (and playlist/album/artist detail) uses to color each row's
|
||||||
|
* [com.fabledsword.minstrel.shared.widgets.LikeButton] without a
|
||||||
|
* per-row Flow subscription.
|
||||||
|
*/
|
||||||
|
val likedTrackIds: StateFlow<Set<String>> =
|
||||||
|
likes.observeLikedTrackIds()
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(SHARE_STOP_TIMEOUT_MS),
|
||||||
|
initialValue = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
fun play() = controller.play()
|
fun play() = controller.play()
|
||||||
fun pause() = controller.pause()
|
fun pause() = controller.pause()
|
||||||
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
fun seekTo(positionMs: Long) = controller.seekTo(positionMs)
|
||||||
@@ -35,4 +56,16 @@ class PlayerViewModel @Inject constructor(
|
|||||||
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
||||||
fun toggleShuffle() = controller.toggleShuffle()
|
fun toggleShuffle() = controller.toggleShuffle()
|
||||||
fun cycleRepeat() = controller.cycleRepeat()
|
fun cycleRepeat() = controller.cycleRepeat()
|
||||||
|
fun moveInQueue(from: Int, to: Int) = controller.moveInQueue(from, to)
|
||||||
|
fun removeFromQueue(index: Int) = controller.removeFromQueue(index)
|
||||||
|
fun clearQueue() = controller.clearQueue()
|
||||||
|
|
||||||
|
fun toggleLikeTrack(trackId: String) {
|
||||||
|
val desired = trackId !in likedTrackIds.value
|
||||||
|
viewModelScope.launch {
|
||||||
|
likes.toggleLike(LikesRepository.ENTITY_TRACK, trackId, desired)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
package com.fabledsword.minstrel.player.ui
|
package com.fabledsword.minstrel.player.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.gestures.detectDragGestures
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -20,22 +28,43 @@ import androidx.compose.material3.Scaffold
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.zIndex
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.composables.icons.lucide.ArrowDown
|
||||||
import com.composables.icons.lucide.ArrowLeft
|
import com.composables.icons.lucide.ArrowLeft
|
||||||
|
import com.composables.icons.lucide.GripVertical
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Music
|
||||||
|
import com.composables.icons.lucide.Trash2
|
||||||
import com.composables.icons.lucide.Volume2
|
import com.composables.icons.lucide.Volume2
|
||||||
|
import com.composables.icons.lucide.X
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -44,16 +73,35 @@ fun QueueScreen(
|
|||||||
viewModel: PlayerViewModel = hiltViewModel(),
|
viewModel: PlayerViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val likedTrackIds by viewModel.likedTrackIds.collectAsStateWithLifecycle()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Queue") },
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("Queue")
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = queueSummary(state.queue),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navController.popBackStack() }) {
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
IconButton(onClick = viewModel::clearQueue) {
|
||||||
|
Icon(Lucide.Trash2, contentDescription = "Clear queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
@@ -67,33 +115,109 @@ fun QueueScreen(
|
|||||||
QueueList(
|
QueueList(
|
||||||
tracks = state.queue,
|
tracks = state.queue,
|
||||||
currentIndex = state.queueIndex,
|
currentIndex = state.queueIndex,
|
||||||
|
likedTrackIds = likedTrackIds,
|
||||||
onJumpTo = viewModel::seekToIndex,
|
onJumpTo = viewModel::seekToIndex,
|
||||||
|
onToggleLike = viewModel::toggleLikeTrack,
|
||||||
|
onMove = viewModel::moveInQueue,
|
||||||
|
onRemove = viewModel::removeFromQueue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueList(
|
private fun QueueList(
|
||||||
tracks: List<TrackRef>,
|
tracks: List<TrackRef>,
|
||||||
currentIndex: Int,
|
currentIndex: Int,
|
||||||
|
likedTrackIds: Set<String>,
|
||||||
onJumpTo: (Int) -> Unit,
|
onJumpTo: (Int) -> Unit,
|
||||||
|
onToggleLike: (String) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
onRemove: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
val listState = rememberLazyListState(
|
||||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||||
QueueRow(
|
)
|
||||||
track = track,
|
val scope = rememberCoroutineScope()
|
||||||
isCurrent = index == currentIndex,
|
|
||||||
onClick = { onJumpTo(index) },
|
// Follow the now-playing row as the track auto-advances, but only while it's
|
||||||
)
|
// near the visible window — if the user has scrolled away to browse, leave
|
||||||
HorizontalDivider()
|
// them there (the pill offers the way back). Parity with the web queue.
|
||||||
|
LaunchedEffect(currentIndex) {
|
||||||
|
if (currentIndex < 0) return@LaunchedEffect
|
||||||
|
val visible = listState.layoutInfo.visibleItemsInfo
|
||||||
|
val first = visible.firstOrNull()?.index ?: 0
|
||||||
|
val last = visible.lastOrNull()?.index ?: 0
|
||||||
|
if (currentIndex in (first - 1)..(last + 1)) {
|
||||||
|
listState.animateScrollToItem(currentIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val currentVisible by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.layoutInfo.visibleItemsInfo.any { it.index == currentIndex }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||||
|
QueueRow(
|
||||||
|
track = track,
|
||||||
|
index = index,
|
||||||
|
queueSize = tracks.size,
|
||||||
|
isCurrent = index == currentIndex,
|
||||||
|
liked = track.id in likedTrackIds,
|
||||||
|
onClick = { onJumpTo(index) },
|
||||||
|
onToggleLike = { onToggleLike(track.id) },
|
||||||
|
onRemove = { onRemove(index) },
|
||||||
|
onMove = onMove,
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JumpToCurrentPill(
|
||||||
|
visible = currentIndex >= 0 && !currentVisible,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
private fun JumpToCurrentPill(
|
||||||
|
visible: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(visible = visible, modifier = modifier) {
|
||||||
|
FilledTonalButton(onClick = onClick) {
|
||||||
|
Icon(Lucide.ArrowDown, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text("Jump to current")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose row wiring — layout + queue callbacks, not logic.
|
||||||
|
@Composable
|
||||||
|
private fun QueueRow(
|
||||||
|
track: TrackRef,
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
|
isCurrent: Boolean,
|
||||||
|
liked: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onToggleLike: () -> Unit,
|
||||||
|
onRemove: () -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
var dragOffsetY by remember { mutableFloatStateOf(0f) }
|
||||||
|
var rowHeightPx by remember { mutableIntStateOf(0) }
|
||||||
val highlight = if (isCurrent) {
|
val highlight = if (isCurrent) {
|
||||||
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
MaterialTheme.colorScheme.primary.copy(alpha = HIGHLIGHT_ALPHA)
|
||||||
} else {
|
} else {
|
||||||
@@ -102,12 +226,23 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
.onSizeChanged { rowHeightPx = it.height }
|
||||||
|
.zIndex(if (dragOffsetY != 0f) 1f else 0f)
|
||||||
|
.graphicsLayer { translationY = dragOffsetY }
|
||||||
.background(highlight)
|
.background(highlight)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
) {
|
||||||
|
DragHandle(
|
||||||
|
index = index,
|
||||||
|
queueSize = queueSize,
|
||||||
|
rowHeightPx = rowHeightPx,
|
||||||
|
onOffsetChange = { dragOffsetY = it },
|
||||||
|
onMove = onMove,
|
||||||
|
)
|
||||||
|
QueueRowThumbnail(track = track)
|
||||||
if (isCurrent) {
|
if (isCurrent) {
|
||||||
Icon(
|
Icon(
|
||||||
Lucide.Volume2,
|
Lucide.Volume2,
|
||||||
@@ -115,26 +250,7 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
|||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
QueueRowText(track = track, isCurrent = isCurrent, modifier = Modifier.weight(1f))
|
||||||
Text(
|
|
||||||
text = track.title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
val subtitle = queueSubtitle(track)
|
|
||||||
if (subtitle.isNotEmpty()) {
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (track.durationSec > 0) {
|
if (track.durationSec > 0) {
|
||||||
Text(
|
Text(
|
||||||
text = formatDuration(track.durationSec),
|
text = formatDuration(track.durationSec),
|
||||||
@@ -142,6 +258,95 @@ private fun QueueRow(track: TrackRef, isCurrent: Boolean, onClick: () -> Unit) {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
LikeButton(liked = liked, onToggle = onToggleLike)
|
||||||
|
IconButton(onClick = onRemove) {
|
||||||
|
Icon(Lucide.X, contentDescription = "Remove from queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DragHandle(
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
|
rowHeightPx: Int,
|
||||||
|
onOffsetChange: (Float) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
) {
|
||||||
|
// Mirrors the web queue: the row follows the finger during a drag, then on
|
||||||
|
// release we translate the accumulated offset into a row delta and reorder.
|
||||||
|
var offset by remember { mutableFloatStateOf(0f) }
|
||||||
|
Icon(
|
||||||
|
Lucide.GripVertical,
|
||||||
|
contentDescription = "Reorder track",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.pointerInput(index, queueSize, rowHeightPx) {
|
||||||
|
detectDragGestures(
|
||||||
|
onDrag = { change, dragAmount ->
|
||||||
|
change.consume()
|
||||||
|
offset += dragAmount.y
|
||||||
|
onOffsetChange(offset)
|
||||||
|
},
|
||||||
|
onDragEnd = {
|
||||||
|
val delta = if (rowHeightPx > 0) (offset / rowHeightPx).roundToInt() else 0
|
||||||
|
val target = (index + delta).coerceIn(0, queueSize - 1)
|
||||||
|
if (target != index) onMove(index, target)
|
||||||
|
offset = 0f
|
||||||
|
onOffsetChange(0f)
|
||||||
|
},
|
||||||
|
onDragCancel = {
|
||||||
|
offset = 0f
|
||||||
|
onOffsetChange(0f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRowThumbnail(track: TrackRef) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
ServerImage(
|
||||||
|
url = track.coverUrl,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Lucide.Music,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QueueRowText(track: TrackRef, isCurrent: Boolean, modifier: Modifier = Modifier) {
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
Text(
|
||||||
|
text = track.title,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = if (isCurrent) FontWeight.Medium else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
val subtitle = queueSubtitle(track)
|
||||||
|
if (subtitle.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,4 +355,18 @@ private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, tr
|
|||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
.joinToString(" · ")
|
.joinToString(" · ")
|
||||||
|
|
||||||
|
/** "N tracks · 12 min" header summary. */
|
||||||
|
private fun queueSummary(tracks: List<TrackRef>): String {
|
||||||
|
val minutes = tracks.sumOf { it.durationSec } / SECONDS_PER_MINUTE
|
||||||
|
val length = if (minutes >= MINUTES_PER_HOUR) {
|
||||||
|
"${minutes / MINUTES_PER_HOUR}h ${minutes % MINUTES_PER_HOUR}m"
|
||||||
|
} else {
|
||||||
|
"$minutes min"
|
||||||
|
}
|
||||||
|
val noun = if (tracks.size == 1) "track" else "tracks"
|
||||||
|
return "${tracks.size} $noun · $length"
|
||||||
|
}
|
||||||
|
|
||||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||||
|
private const val SECONDS_PER_MINUTE = 60
|
||||||
|
private const val MINUTES_PER_HOUR = 60
|
||||||
|
|||||||
+3
-3
@@ -119,9 +119,9 @@ class PlaylistsRepository @Inject constructor(
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
||||||
playlistTrackDao.deleteByPlaylist(id)
|
playlistTrackDao.replacePlaylistTracks(
|
||||||
playlistTrackDao.upsertAll(
|
playlistId = id,
|
||||||
wire.tracks.mapNotNull { row ->
|
rows = wire.tracks.mapNotNull { row ->
|
||||||
row.trackId?.let { trackId ->
|
row.trackId?.let { trackId ->
|
||||||
CachedPlaylistTrackEntity(
|
CachedPlaylistTrackEntity(
|
||||||
playlistId = id,
|
playlistId = id,
|
||||||
|
|||||||
+17
-30
@@ -38,9 +38,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -60,6 +58,8 @@ import com.composables.icons.lucide.Play
|
|||||||
import com.composables.icons.lucide.RefreshCw
|
import com.composables.icons.lucide.RefreshCw
|
||||||
import com.composables.icons.lucide.Shuffle
|
import com.composables.icons.lucide.Shuffle
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEvent
|
import com.fabledsword.minstrel.events.LiveEvent
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
@@ -76,6 +76,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
|||||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
||||||
@@ -117,6 +118,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -167,6 +169,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { handlePlaylistEvent(it) }
|
.collect { handlePlaylistEvent(it) }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||||
|
// load while the server was unreachable re-fetches when health
|
||||||
|
// returns, instead of waiting for the Retry tap.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is PlaylistDetailUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,7 +379,11 @@ private fun PlaylistDetailContent(
|
|||||||
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||||
is PlaylistDetailUiState.Loading ->
|
is PlaylistDetailUiState.Loading ->
|
||||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
is PlaylistDetailUiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load playlist",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
|
)
|
||||||
is PlaylistDetailUiState.Success -> {
|
is PlaylistDetailUiState.Success -> {
|
||||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||||
val playerState by playerViewModel.uiState.collectAsState()
|
val playerState by playerViewModel.uiState.collectAsState()
|
||||||
@@ -653,33 +667,6 @@ private fun SeededPlaylistLoading(seed: PlaylistRef) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
|
|
||||||
var retried by remember { mutableStateOf(false) }
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
if (!retried) {
|
|
||||||
retried = true
|
|
||||||
onRetry()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text("Retry") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private const val UNAVAILABLE_ALPHA = 0.4f
|
private const val UNAVAILABLE_ALPHA = 0.4f
|
||||||
|
|||||||
+26
-9
@@ -27,8 +27,10 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||||
@@ -40,6 +42,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -47,8 +50,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
@@ -84,6 +89,9 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Copy for the most recent list-refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Live updates: a playlist created/updated/deleted from another
|
// Live updates: a playlist created/updated/deleted from another
|
||||||
@@ -93,11 +101,18 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshList() }
|
runCatching { repository.refreshList() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
||||||
@@ -110,14 +125,15 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||||
repository.observeAll()
|
combine(repository.observeAll(), refreshError) { list, err ->
|
||||||
.map { list ->
|
when {
|
||||||
if (list.isEmpty()) {
|
list.isNotEmpty() -> UiState.Success(list)
|
||||||
UiState.Empty
|
// Empty cache + failed refresh is a load failure, not "no
|
||||||
} else {
|
// playlists yet" — surface Error so the user gets a Retry.
|
||||||
UiState.Success(list)
|
err != null -> UiState.Error(err)
|
||||||
}
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +173,10 @@ fun PlaylistsListScreen(
|
|||||||
"appear once your library has enough plays. Create your " +
|
"appear once your library has enough plays. Create your " +
|
||||||
"own playlist from any album or track in the meantime.",
|
"own playlist from any album or track in the meantime.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load playlists",
|
title = "Couldn't load playlists",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> PlaylistsGrid(
|
is UiState.Success -> PlaylistsGrid(
|
||||||
playlists = s.data,
|
playlists = s.data,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -43,9 +44,10 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
|
|||||||
"and it'll show up here. The track stays out of system " +
|
"and it'll show up here. The track stays out of system " +
|
||||||
"playlists until you unhide it.",
|
"playlists until you unhide it.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load hidden tracks",
|
title = "Couldn't load hidden tracks",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HiddenList(
|
is UiState.Success -> HiddenList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.quarantine.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
@@ -24,6 +26,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HiddenTabViewModel @Inject constructor(
|
class HiddenTabViewModel @Inject constructor(
|
||||||
private val repository: QuarantineRepository,
|
private val repository: QuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -61,6 +64,11 @@ class HiddenTabViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -78,9 +79,10 @@ fun RequestsScreen(
|
|||||||
body = "Use Discover to ask Lidarr for new music; your " +
|
body = "Use Discover to ask Lidarr for new music; your " +
|
||||||
"requests show up here.",
|
"requests show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> RequestList(
|
is UiState.Success -> RequestList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
+16
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.requests.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.models.RequestStatus
|
import com.fabledsword.minstrel.models.RequestStatus
|
||||||
@@ -30,6 +32,7 @@ private const val POLL_INTERVAL_MS = 12_000L
|
|||||||
class RequestsViewModel @Inject constructor(
|
class RequestsViewModel @Inject constructor(
|
||||||
private val repository: RequestsRepository,
|
private val repository: RequestsRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
||||||
@@ -45,6 +48,14 @@ class RequestsViewModel @Inject constructor(
|
|||||||
.collect { silentReload() }
|
.collect { silentReload() }
|
||||||
}
|
}
|
||||||
viewModelScope.launch { pollWhileInFlight() }
|
viewModelScope.launch { pollWhileInFlight() }
|
||||||
|
// Screen-level auto-recovery (issue #1245): a load that failed while
|
||||||
|
// the server was unreachable heals the moment health returns, without
|
||||||
|
// waiting for the next poll tick.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is UiState.Error) silentReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -71,7 +82,11 @@ class RequestsViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private suspend fun pollWhileInFlight() {
|
private suspend fun pollWhileInFlight() {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (hasInFlightRequest()) {
|
// Also retry from a failed load (issue #1245): from UiState.Error
|
||||||
|
// the in-flight predicate can never become true, so without this
|
||||||
|
// the poll never self-heals the screen — e.g. after a transient
|
||||||
|
// 500 that health monitoring (transport-level) never saw.
|
||||||
|
if (hasInFlightRequest() || internal.value is UiState.Error) {
|
||||||
silentReload()
|
silentReload()
|
||||||
}
|
}
|
||||||
delay(POLL_INTERVAL_MS)
|
delay(POLL_INTERVAL_MS)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -113,6 +114,7 @@ fun SearchScreen(
|
|||||||
onTrackPlay = viewModel::playTrack,
|
onTrackPlay = viewModel::playTrack,
|
||||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onRetry = viewModel::retry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,11 +153,16 @@ private fun ResultsPane(
|
|||||||
onTrackPlay: (TrackRef) -> Unit,
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
onNavigateToAlbum: (String) -> Unit,
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
onNavigateToArtist: (String) -> Unit,
|
onNavigateToArtist: (String) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||||
SearchResultsState.Loading -> LoadingCentered()
|
SearchResultsState.Loading -> LoadingCentered()
|
||||||
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
is SearchResultsState.Error -> ErrorRetry(
|
||||||
|
title = "Search failed",
|
||||||
|
message = state.message,
|
||||||
|
onRetry = onRetry,
|
||||||
|
)
|
||||||
is SearchResultsState.Loaded -> {
|
is SearchResultsState.Loaded -> {
|
||||||
if (state.response.isEmpty) {
|
if (state.response.isEmpty) {
|
||||||
CenteredHint(
|
CenteredHint(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.search.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -43,6 +45,7 @@ data class SearchState(
|
|||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
private val repository: SearchRepository,
|
private val repository: SearchRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val queryFlow = MutableStateFlow("")
|
private val queryFlow = MutableStateFlow("")
|
||||||
@@ -69,6 +72,14 @@ class SearchViewModel @Inject constructor(
|
|||||||
runSearch(q)
|
runSearch(q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): a search that failed
|
||||||
|
// while the server was unreachable re-runs when health returns —
|
||||||
|
// the queryFlow pipeline can't, since the text hasn't changed.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value.results is SearchResultsState.Error) retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
@@ -80,6 +91,18 @@ class SearchViewModel @Inject constructor(
|
|||||||
setQuery("")
|
setQuery("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-run the current query after a failure. Bypasses the queryFlow
|
||||||
|
* pipeline, whose distinctUntilChanged drops a same-text resubmit —
|
||||||
|
* without this, a transient error pins "Search failed" until the
|
||||||
|
* user edits the query.
|
||||||
|
*/
|
||||||
|
fun retry() {
|
||||||
|
val q = internal.value.query.trim()
|
||||||
|
if (q.isEmpty()) return
|
||||||
|
viewModelScope.launch { runSearch(q) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tapping a search result builds a queue from the full visible
|
* Tapping a search result builds a queue from the full visible
|
||||||
* track-results list starting at the tapped entry, matching Flutter.
|
* track-results list starting at the tapped entry, matching Flutter.
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package com.fabledsword.minstrel.shared
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
// Once raised, the veil stays up at least this long. Without a floor a
|
||||||
|
// no-op refresh wipes on and straight back off, which reads as a glitch.
|
||||||
|
private const val DEFAULT_MIN_HOLD_MS = 900L
|
||||||
|
|
||||||
|
// The screen must stop changing for this long before the veil lowers.
|
||||||
|
// Every content change re-arms it, so a refresh that lands in stages
|
||||||
|
// (index → tile hydration → artwork) holds the veil across all of them.
|
||||||
|
private const val DEFAULT_QUIET_MS = 700L
|
||||||
|
|
||||||
|
// Hard ceiling on visibility. A refresh that never settles must not
|
||||||
|
// strand the user behind an opaque veil; the work itself is NOT capped.
|
||||||
|
private const val DEFAULT_MAX_HOLD_MS = 12_000L
|
||||||
|
|
||||||
|
// Attempts per session. Retrying behind the veil is the point: a pull
|
||||||
|
// that fails on the first try gets another go before the user sees
|
||||||
|
// anything, instead of the veil wiping off over unchanged content.
|
||||||
|
private const val DEFAULT_ATTEMPTS = 3
|
||||||
|
private const val DEFAULT_RETRY_BACKOFF_MS = 600L
|
||||||
|
|
||||||
|
/** Tunables for [UpdateVeilController]; defaults are the Home values. */
|
||||||
|
data class VeilTimings(
|
||||||
|
val minHoldMs: Long = DEFAULT_MIN_HOLD_MS,
|
||||||
|
val quietMs: Long = DEFAULT_QUIET_MS,
|
||||||
|
val maxHoldMs: Long = DEFAULT_MAX_HOLD_MS,
|
||||||
|
val attempts: Int = DEFAULT_ATTEMPTS,
|
||||||
|
val retryBackoffMs: Long = DEFAULT_RETRY_BACKOFF_MS,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A snapshot of everything that visibly moves on the veiled screen.
|
||||||
|
*
|
||||||
|
* @param contentKey any value whose equality tracks what's rendered — a
|
||||||
|
* change means the screen moved, and re-arms the quiet timer.
|
||||||
|
* @param hasContent true when real content (not a skeleton or an empty
|
||||||
|
* state) is on screen. The veil waits for this before raising: there's
|
||||||
|
* nothing to hide until there's something to hide.
|
||||||
|
* @param quiescent false while something is still landing (artwork
|
||||||
|
* loading, tiles hydrating). The veil will not lower until this is
|
||||||
|
* true, up to [VeilTimings.maxHoldMs].
|
||||||
|
*/
|
||||||
|
data class VeilSettleState(
|
||||||
|
val contentKey: Any?,
|
||||||
|
val hasContent: Boolean,
|
||||||
|
val quiescent: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What a finished session did, so callers can report it if they want. */
|
||||||
|
enum class VeilOutcome {
|
||||||
|
/** Content changed, and the veil covered the churn. */
|
||||||
|
CHANGED,
|
||||||
|
|
||||||
|
/** The refresh worked, but nothing on screen moved — already current. */
|
||||||
|
UNCHANGED,
|
||||||
|
|
||||||
|
/** Every attempt failed. */
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One session's result, plus whether a user explicitly asked for it.
|
||||||
|
*
|
||||||
|
* [userInitiated] is what lets a caller tell feedback from noise: a user
|
||||||
|
* who pulled to refresh is owed an answer even when the answer is "nothing
|
||||||
|
* changed", while the same outcome from a background check is noise.
|
||||||
|
*/
|
||||||
|
data class VeilSessionResult(
|
||||||
|
val outcome: VeilOutcome,
|
||||||
|
val userInitiated: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives an "updating" overlay from *observed content change and settling*
|
||||||
|
* rather than from a fixed delay.
|
||||||
|
*
|
||||||
|
* The problem this replaces: a veil held for `refresh().join() + 500ms`
|
||||||
|
* lowers while the screen is still moving, because finishing the network
|
||||||
|
* pull is nowhere near the end of the visible work — the pull writes id
|
||||||
|
* lists, then tiles hydrate one by one, then artwork loads. And a plain
|
||||||
|
* `isUpdating` Boolean set in a `finally` gets cleared by whichever of
|
||||||
|
* two overlapping refreshes finishes first, wiping the veil off mid-update
|
||||||
|
* (issue #2327).
|
||||||
|
*
|
||||||
|
* So instead: run [work], raise only if the content actually changes, then
|
||||||
|
* hold until [settleSignal] reports the screen has stopped changing for
|
||||||
|
* [VeilTimings.quietMs] AND is quiescent — bounded below by
|
||||||
|
* [VeilTimings.minHoldMs] so it can never flash, and above by
|
||||||
|
* [VeilTimings.maxHoldMs] so it can never strand.
|
||||||
|
*
|
||||||
|
* The raise is deliberately *reactive*: a refresh that returns what's
|
||||||
|
* already on screen — the common case on a launch over a warm cache —
|
||||||
|
* raises nothing at all, because a veil over an unchanged screen hides
|
||||||
|
* nothing and only delays first paint. The cost is that the veil arrives
|
||||||
|
* one emission after the change, so a single atomic content swap shows
|
||||||
|
* through; everything messier that follows it (tile hydration, then
|
||||||
|
* artwork) still lands behind the veil.
|
||||||
|
*
|
||||||
|
* Overlapping triggers extend the running session instead of racing it,
|
||||||
|
* so the veil stays up continuously rather than lowering and re-raising.
|
||||||
|
*
|
||||||
|
* Failure is quiet at this layer: [work] gets [VeilTimings.attempts] tries
|
||||||
|
* behind the veil, and if they all fail the veil simply wipes off over the
|
||||||
|
* cached content. Giving up ends only *this* session — it sets no latch and
|
||||||
|
* blocks nothing, so the caller's own recovery paths (reconnect re-pull,
|
||||||
|
* freshness sweeps, the next event, a manual pull) keep retrying afterwards
|
||||||
|
* exactly as before. Callers that want to surface a failure can do it from
|
||||||
|
* [onSessionEnd] instead.
|
||||||
|
*
|
||||||
|
* @param work one refresh attempt; returns true when it succeeded.
|
||||||
|
* @param shouldVeil sampled at session start — "is there cached content
|
||||||
|
* this refresh is about to overwrite?". False means a cold load, where
|
||||||
|
* a skeleton is the right affordance, and the work runs unveiled.
|
||||||
|
* @param onSessionEnd called once per finished session, on the controller's
|
||||||
|
* coroutine. Use it for user-facing feedback the veil itself can't give.
|
||||||
|
*/
|
||||||
|
class UpdateVeilController(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val settleSignal: Flow<VeilSettleState>,
|
||||||
|
private val shouldVeil: suspend () -> Boolean,
|
||||||
|
private val timings: VeilTimings = VeilTimings(),
|
||||||
|
private val onSessionEnd: (VeilSessionResult) -> Unit = {},
|
||||||
|
private val work: suspend () -> Boolean,
|
||||||
|
) {
|
||||||
|
private val visibleInternal = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** True while the veil should be drawn over the screen. */
|
||||||
|
val visible: StateFlow<Boolean> = visibleInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val finishedInternal = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments as each session ends. Lets a caller wait for "this
|
||||||
|
* refresh is done" without knowing whether a veil ever went up —
|
||||||
|
* a pull-to-refresh indicator needs exactly that, since an unchanged
|
||||||
|
* refresh never raises one.
|
||||||
|
*/
|
||||||
|
val finishedSessions: StateFlow<Int> = finishedInternal.asStateFlow()
|
||||||
|
|
||||||
|
// Conflated: a burst of triggers (reconnect + rebuild event arriving
|
||||||
|
// together) collapses into one follow-up pass, not a queue of them.
|
||||||
|
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||||
|
|
||||||
|
// Sticky across a conflated burst: conflation drops the older token, so
|
||||||
|
// the "a user asked for this" bit can't ride on it. If ANY coalesced
|
||||||
|
// trigger was the user's, the session still owes them an answer.
|
||||||
|
private val userAsked = AtomicBoolean(false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// One consumer, so sessions are serialised by construction: two
|
||||||
|
// triggers can never each own a piece of the veil's state.
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
requests.receive()
|
||||||
|
runSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for a refresh. Safe to call from any trigger at any rate —
|
||||||
|
* calls arriving during a session extend it rather than starting a
|
||||||
|
* competing one.
|
||||||
|
*
|
||||||
|
* @param userInitiated true when a person explicitly asked (pull to
|
||||||
|
* refresh, a Retry button), which is what [VeilSessionResult] carries
|
||||||
|
* through to [onSessionEnd].
|
||||||
|
*/
|
||||||
|
fun request(userInitiated: Boolean = false) {
|
||||||
|
if (userInitiated) userAsked.set(true)
|
||||||
|
requests.trySend(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runSession() {
|
||||||
|
// Sampled at both ends of the work: a trigger folded in mid-session
|
||||||
|
// (see [drainWork]) may have been the user's, and they're still owed
|
||||||
|
// an answer for it.
|
||||||
|
val askedAtStart = userAsked.getAndSet(false)
|
||||||
|
if (!shouldVeil()) {
|
||||||
|
// Cold load: the skeleton is the right affordance, so no veil.
|
||||||
|
// Succeeding here did change the screen — from nothing to
|
||||||
|
// something — so it reports CHANGED, never "already up to date".
|
||||||
|
val ok = drainWork()
|
||||||
|
finish(succeeded = ok, changed = ok, userInitiated = askedAtStart)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val raised = CompletableDeferred<Unit>()
|
||||||
|
val raiser = scope.launch { raiseWhenContentChanges(raised) }
|
||||||
|
// Floor and ceiling are measured from the raise, not the request,
|
||||||
|
// so a late raise still gets its full no-flash minimum.
|
||||||
|
val floor = scope.launch {
|
||||||
|
raised.await()
|
||||||
|
delay(timings.minHoldMs)
|
||||||
|
}
|
||||||
|
val ceiling = scope.launch {
|
||||||
|
raised.await()
|
||||||
|
delay(timings.maxHoldMs)
|
||||||
|
visibleInternal.value = false
|
||||||
|
}
|
||||||
|
var succeeded = false
|
||||||
|
try {
|
||||||
|
succeeded = drainWork()
|
||||||
|
// Always wait for the settle, never conditionally on `visible`:
|
||||||
|
// work that finishes without suspending would otherwise reach
|
||||||
|
// here before the raiser has been dispatched, tear the session
|
||||||
|
// down, and leave the churn uncovered. This wait is also what
|
||||||
|
// makes `raised.isCompleted` below a trustworthy "did anything
|
||||||
|
// change?" — a change landing just after the pull returns still
|
||||||
|
// gets seen.
|
||||||
|
withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() }
|
||||||
|
// Honour the no-flash minimum before lowering. Deliberately in
|
||||||
|
// the try and not the finally: on cancellation the scope is
|
||||||
|
// going away and nothing will render the veil, so the floor is
|
||||||
|
// pointless there — and a finally that suspends is a finally
|
||||||
|
// that can resist teardown.
|
||||||
|
if (raised.isCompleted) floor.join()
|
||||||
|
} finally {
|
||||||
|
raiser.cancel()
|
||||||
|
ceiling.cancel()
|
||||||
|
floor.cancel()
|
||||||
|
visibleInternal.value = false
|
||||||
|
finish(
|
||||||
|
succeeded = succeeded,
|
||||||
|
changed = raised.isCompleted,
|
||||||
|
userInitiated = askedAtStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises the veil the moment the screen's content differs from what was
|
||||||
|
* already on it — and never, if this refresh turns out to be a no-op.
|
||||||
|
*
|
||||||
|
* The baseline is the first state that HAS content, not simply the first
|
||||||
|
* state: over a warm cache the cached rows paint a moment after the
|
||||||
|
* session starts, and treating that first paint as "a change" would veil
|
||||||
|
* every launch, which is the whole thing this avoids.
|
||||||
|
*/
|
||||||
|
private suspend fun raiseWhenContentChanges(raised: CompletableDeferred<Unit>) {
|
||||||
|
val baseline = settleSignal.first { it.hasContent }
|
||||||
|
settleSignal.first { it.hasContent && it.contentKey != baseline.contentKey }
|
||||||
|
visibleInternal.value = true
|
||||||
|
raised.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(succeeded: Boolean, changed: Boolean, userInitiated: Boolean) {
|
||||||
|
val outcome = when {
|
||||||
|
!succeeded -> VeilOutcome.FAILED
|
||||||
|
changed -> VeilOutcome.CHANGED
|
||||||
|
else -> VeilOutcome.UNCHANGED
|
||||||
|
}
|
||||||
|
onSessionEnd(
|
||||||
|
VeilSessionResult(
|
||||||
|
outcome = outcome,
|
||||||
|
// Fold in a mid-session request from the user.
|
||||||
|
userInitiated = userInitiated || userAsked.getAndSet(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finishedInternal.update { it + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the refresh eventually succeeded. */
|
||||||
|
private suspend fun drainWork(): Boolean {
|
||||||
|
var succeeded: Boolean
|
||||||
|
do {
|
||||||
|
succeeded = runWorkWithRetries()
|
||||||
|
// A trigger that arrived mid-session gets folded into this one.
|
||||||
|
} while (requests.tryReceive().isSuccess)
|
||||||
|
return succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runWorkWithRetries(): Boolean {
|
||||||
|
repeat(timings.attempts) { attempt ->
|
||||||
|
if (work()) return true
|
||||||
|
if (attempt < timings.attempts - 1) {
|
||||||
|
delay(timings.retryBackoffMs * (attempt + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends until the screen has been unchanged for
|
||||||
|
* [VeilTimings.quietMs] and reports itself quiescent.
|
||||||
|
*
|
||||||
|
* `debounce` is what makes this hold across a staged update: every
|
||||||
|
* change restarts the window, so the veil lowers only once emissions
|
||||||
|
* actually stop. `first { quiescent }` then rejects a quiet-but-
|
||||||
|
* still-loading moment and waits for the next lull.
|
||||||
|
*/
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
private suspend fun awaitSettled() {
|
||||||
|
settleSignal
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.debounce(timings.quietMs)
|
||||||
|
.first { it.quiescent }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the cover-art loads that are currently in flight, so a
|
||||||
|
* screen-level overlay can wait for the artwork to actually land instead
|
||||||
|
* of guessing with a fixed delay.
|
||||||
|
*
|
||||||
|
* Artwork is the most visible pop-in on Home: a tile can be fully
|
||||||
|
* hydrated (title, artist, counts all present) and still snap its cover
|
||||||
|
* in a second later, which is exactly the churn the "Updating your
|
||||||
|
* mixes…" veil exists to hide. The refresh coroutine can't see that —
|
||||||
|
* it finishes long before Coil does — so the composition reports it
|
||||||
|
* upward here instead.
|
||||||
|
*
|
||||||
|
* [ServerImage] reports into whatever tracker it finds in
|
||||||
|
* [LocalArtSettleTracker], which means every art surface in the app
|
||||||
|
* participates for free. Only *composed* images are counted, so a
|
||||||
|
* LazyRow's off-screen tiles are correctly ignored — the count tracks
|
||||||
|
* the pop-in a user can actually see.
|
||||||
|
*
|
||||||
|
* Provide one per screen that needs it (typically owned by the
|
||||||
|
* screen's ViewModel so its refresh logic can read [inFlight]):
|
||||||
|
*
|
||||||
|
* CompositionLocalProvider(LocalArtSettleTracker provides vm.artTracker) { ... }
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class ArtSettleTracker {
|
||||||
|
private val inFlightInternal = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many on-screen images are still loading. Zero means the
|
||||||
|
* artwork has settled — every composed cover has either drawn or
|
||||||
|
* failed to a fallback.
|
||||||
|
*/
|
||||||
|
val inFlight: StateFlow<Int> = inFlightInternal.asStateFlow()
|
||||||
|
|
||||||
|
fun begin() {
|
||||||
|
inFlightInternal.update { it + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun end() {
|
||||||
|
// Floor at zero: a decrement can outlive its increment when a
|
||||||
|
// tile is disposed mid-load and the count must not go negative
|
||||||
|
// and wedge "settled" off forever.
|
||||||
|
inFlightInternal.update { (it - 1).coerceAtLeast(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tracker [ServerImage] reports load state to, or null on screens
|
||||||
|
* that don't care (the default) — reporting is then a no-op.
|
||||||
|
*/
|
||||||
|
val LocalArtSettleTracker = staticCompositionLocalOf<ArtSettleTracker?> { null }
|
||||||
@@ -23,8 +23,12 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
|||||||
* ServerImage + fallback structure was identical at all three sites.
|
* ServerImage + fallback structure was identical at all three sites.
|
||||||
*
|
*
|
||||||
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
||||||
* (e.g. the `VariantPill` system-playlist label) — callers can use
|
* (e.g. the `VariantPill` system-playlist label, the play button) —
|
||||||
* `Modifier.align(...)` inside it.
|
* callers can use `Modifier.align(...)` inside it. The overlay sits on
|
||||||
|
* an outer, UN-clipped box so corner-anchored widgets are not cut off by
|
||||||
|
* [shape]: a `BottomEnd` play button on a `CircleShape` avatar falls in
|
||||||
|
* the square's corner, outside the circle, and would otherwise be clipped
|
||||||
|
* away. Only the artwork + background are clipped to [shape].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CoverTile(
|
fun CoverTile(
|
||||||
@@ -38,18 +42,23 @@ fun CoverTile(
|
|||||||
overlay: @Composable BoxScope.() -> Unit = {},
|
overlay: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier.size(size),
|
||||||
.size(size)
|
|
||||||
.clip(shape)
|
|
||||||
.background(background),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
ServerImage(
|
Box(
|
||||||
url = url,
|
modifier = Modifier
|
||||||
contentDescription = contentDescription,
|
.fillMaxSize()
|
||||||
modifier = Modifier.fillMaxSize(),
|
.clip(shape)
|
||||||
|
.background(background),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
fallback()
|
ServerImage(
|
||||||
|
url = url,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
overlay()
|
overlay()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -23,44 +24,63 @@ import com.fabledsword.minstrel.theme.LocalActionColors
|
|||||||
* Shared error-with-retry widget. Surfaces a brief message and a
|
* Shared error-with-retry widget. Surfaces a brief message and a
|
||||||
* primary action button. Per the design system, the button uses
|
* primary action button. Per the design system, the button uses
|
||||||
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
||||||
|
*
|
||||||
|
* Renders inside a single-item LazyColumn (same rationale as
|
||||||
|
* [EmptyState]) so the widget participates in nested-scroll dispatch
|
||||||
|
* under PullToRefreshBox — both the Retry button and a pull gesture
|
||||||
|
* can recover from the error state.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorRetry(
|
fun ErrorRetry(
|
||||||
message: String,
|
message: String,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
title: String? = null,
|
||||||
retryLabel: String = "Retry",
|
retryLabel: String = "Retry",
|
||||||
) {
|
) {
|
||||||
val actionColors = LocalActionColors.current
|
val actionColors = LocalActionColors.current
|
||||||
Column(
|
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||||
modifier = modifier
|
item {
|
||||||
.fillMaxSize()
|
Column(
|
||||||
.padding(32.dp),
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.Center,
|
.fillParentMaxSize()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.padding(32.dp),
|
||||||
) {
|
verticalArrangement = Arrangement.Center,
|
||||||
Icon(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
imageVector = Lucide.TriangleAlert,
|
) {
|
||||||
contentDescription = null,
|
Icon(
|
||||||
modifier = Modifier.size(48.dp),
|
imageVector = Lucide.TriangleAlert,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.size(48.dp),
|
||||||
Text(
|
tint = MaterialTheme.colorScheme.error,
|
||||||
text = message,
|
)
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
if (title != null) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
text = title,
|
||||||
textAlign = TextAlign.Center,
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
)
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Button(
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
onClick = onRetry,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
)
|
||||||
colors = ButtonDefaults.buttonColors(
|
}
|
||||||
containerColor = actionColors.primary,
|
Text(
|
||||||
contentColor = actionColors.onAction,
|
text = message,
|
||||||
),
|
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
|
||||||
) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Text(retryLabel)
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = actionColors.primary,
|
||||||
|
contentColor = actionColors.onAction,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(retryLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,40 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.compose.AsyncImagePainter
|
import coil3.compose.AsyncImagePainter
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
|
|
||||||
|
// The fallback fades out as the artwork crossfades in (Coil's crossfade is
|
||||||
|
// configured globally on the ImageLoader in MinstrelApplication). Matching
|
||||||
|
// durations makes the swap read as one cross-dissolve; without the fade the
|
||||||
|
// placeholder icon vanished a frame before the cover appeared, which is the
|
||||||
|
// "art popping in" the Home veil exists to hide (issue #2327).
|
||||||
|
private const val FALLBACK_FADE_MS = 220
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||||
* is blank/unresolvable, while the image is still loading, and when the
|
* is blank/unresolvable, while the image is still loading, and when the
|
||||||
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||||
* which the "You might like" row hits often).
|
* which the "You might like" row hits often).
|
||||||
|
*
|
||||||
|
* In-flight loads are reported to [LocalArtSettleTracker] when a screen
|
||||||
|
* provides one, so a screen-level overlay can wait for artwork to land
|
||||||
|
* instead of guessing with a fixed delay.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerImage(
|
fun ServerImage(
|
||||||
@@ -40,6 +55,23 @@ fun ServerImage(
|
|||||||
var state by remember(resolved) {
|
var state by remember(resolved) {
|
||||||
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
||||||
}
|
}
|
||||||
|
// Empty counts as loading: it's the pre-request state, so treating it
|
||||||
|
// as settled would let a screen overlay lower before Coil even starts.
|
||||||
|
val loading = state is AsyncImagePainter.State.Empty ||
|
||||||
|
state is AsyncImagePainter.State.Loading
|
||||||
|
val tracker = LocalArtSettleTracker.current
|
||||||
|
DisposableEffect(tracker, loading) {
|
||||||
|
if (loading) tracker?.begin()
|
||||||
|
// Balanced by construction: the effect re-runs when `loading` flips
|
||||||
|
// (decrement, then no re-increment) and disposes when a tile leaves
|
||||||
|
// the composition mid-load (scrolled away).
|
||||||
|
onDispose { if (loading) tracker?.end() }
|
||||||
|
}
|
||||||
|
val fallbackAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (loading || state is AsyncImagePainter.State.Error) 1f else 0f,
|
||||||
|
animationSpec = tween(FALLBACK_FADE_MS),
|
||||||
|
label = "art-fallback",
|
||||||
|
)
|
||||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = resolved,
|
model = resolved,
|
||||||
@@ -48,10 +80,10 @@ fun ServerImage(
|
|||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
onState = { state = it },
|
onState = { state = it },
|
||||||
)
|
)
|
||||||
if (state is AsyncImagePainter.State.Loading ||
|
if (fallbackAlpha > 0f) {
|
||||||
state is AsyncImagePainter.State.Error
|
Box(Modifier.alpha(fallbackAlpha), contentAlignment = Alignment.Center) {
|
||||||
) {
|
fallback()
|
||||||
fallback()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+132
@@ -0,0 +1,132 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse rules for desired-state toggles in the offline queue.
|
||||||
|
*
|
||||||
|
* The hazard this guards against is real and silent: without collapsing, a
|
||||||
|
* queued snooze that replays AFTER the user's undo re-hides an artist they
|
||||||
|
* asked to see again, and nothing surfaces the contradiction.
|
||||||
|
*/
|
||||||
|
class SupersededToggleIdsTest {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun snoozeRow(id: Long, mbid: String, desiredSnoozed: Boolean) = CachedMutationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
SuggestionSnoozeTogglePayload.serializer(),
|
||||||
|
SuggestionSnoozeTogglePayload(mbid, "Name", desiredSnoozed),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun likeRow(id: Long, entityId: String, desired: Boolean) = CachedMutationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = MutationKind.LIKE_TOGGLE,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
LikeTogglePayload.serializer(),
|
||||||
|
LikeTogglePayload("artist", entityId, desired),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a snooze followed by its undo drops the snooze`() {
|
||||||
|
val rows = listOf(
|
||||||
|
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = false),
|
||||||
|
)
|
||||||
|
// Only the later intent (the undo) survives to be replayed.
|
||||||
|
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggles for different candidates never collapse into each other`() {
|
||||||
|
val rows = listOf(
|
||||||
|
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||||
|
snoozeRow(2, "mb-b", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only the newest of several toggles for one candidate survives`() {
|
||||||
|
val rows = listOf(
|
||||||
|
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = false),
|
||||||
|
snoozeRow(3, "mb-a", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertEquals(setOf(1L, 2L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The kind is part of the collapse key, so a snooze and a like that happen
|
||||||
|
// to share an id string must not shadow one another.
|
||||||
|
@Test
|
||||||
|
fun `a like and a snooze on the same id string do not collide`() {
|
||||||
|
val rows = listOf(
|
||||||
|
likeRow(1, "same-id", desired = true),
|
||||||
|
snoozeRow(2, "same-id", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `like toggles still collapse — the pre-existing behaviour is intact`() {
|
||||||
|
val rows = listOf(
|
||||||
|
likeRow(1, "artist-1", desired = true),
|
||||||
|
likeRow(2, "artist-1", desired = false),
|
||||||
|
)
|
||||||
|
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-toggle kinds are never collapsed, even repeated for one entity`() {
|
||||||
|
// Two appends to the same playlist are two real actions, not one
|
||||||
|
// desired state — collapsing them would lose a write.
|
||||||
|
val rows = listOf(
|
||||||
|
CachedMutationEntity(
|
||||||
|
id = 1,
|
||||||
|
kind = MutationKind.PLAYLIST_APPEND,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
PlaylistAppendPayload.serializer(),
|
||||||
|
PlaylistAppendPayload("pl-1", listOf("t1")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CachedMutationEntity(
|
||||||
|
id = 2,
|
||||||
|
kind = MutationKind.PLAYLIST_APPEND,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
PlaylistAppendPayload.serializer(),
|
||||||
|
PlaylistAppendPayload("pl-1", listOf("t2")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row whose payload won't decode gets no key at all, rather than sharing
|
||||||
|
// a "corrupt" bucket — otherwise one bad row could suppress a good one
|
||||||
|
// behind it. The dispatcher DROPs the bad row on its own.
|
||||||
|
@Test
|
||||||
|
fun `an undecodable payload does not suppress a valid later row`() {
|
||||||
|
val rows = listOf(
|
||||||
|
CachedMutationEntity(
|
||||||
|
id = 1,
|
||||||
|
kind = MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||||
|
payload = "{ not json",
|
||||||
|
),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty queue collapses nothing`() {
|
||||||
|
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,4 +56,51 @@ class BuildPlaylistsRowTest {
|
|||||||
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
||||||
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun songsLike(id: String, cached: Boolean) = PlaylistRef(
|
||||||
|
id = id,
|
||||||
|
userId = "u",
|
||||||
|
name = "Songs like $id",
|
||||||
|
systemVariant = "songs_like_artist",
|
||||||
|
trackCount = 25,
|
||||||
|
fullyCached = cached,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `songs-like no longer appears in the main playlists carousel`() {
|
||||||
|
val owned = listOf(songsLike("a", cached = true), user("u1", cached = true))
|
||||||
|
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
assertTrue("a" !in reals, "songs-like mix leaked into the Playlists row: $reals")
|
||||||
|
assertTrue("u1" in reals)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `online songs-like row shows every generated mix uncapped`() {
|
||||||
|
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
|
||||||
|
val owned = (1..6).map { songsLike("a$it", cached = true) }
|
||||||
|
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
assertEquals((1..6).map { "a$it" }, reals)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `online songs-like row shows placeholders when none generated`() {
|
||||||
|
val row = buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = false)
|
||||||
|
assertTrue(row.isNotEmpty())
|
||||||
|
assertTrue(row.all { it is PlaylistRowItem.Placeholder })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `offline songs-like row shows cached mixes available-first, none hides it`() {
|
||||||
|
val owned = listOf(songsLike("partial", cached = false), songsLike("full", cached = true))
|
||||||
|
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = true)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
// Fully-cached songs-like mix (not refreshable) is available; the
|
||||||
|
// un-cached one greys and sorts after. No placeholders offline.
|
||||||
|
assertEquals(listOf("full", "partial"), reals)
|
||||||
|
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
|
||||||
|
|
||||||
|
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -11,6 +11,7 @@ import com.fabledsword.minstrel.testutil.MainDispatcherExtension
|
|||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -21,6 +22,13 @@ import kotlin.test.assertEquals
|
|||||||
@ExtendWith(MainDispatcherExtension::class)
|
@ExtendWith(MainDispatcherExtension::class)
|
||||||
class LibraryViewModelTest {
|
class LibraryViewModelTest {
|
||||||
|
|
||||||
|
// lastSyncError needs a real StateFlow: the VM combines it into uiState,
|
||||||
|
// and a relaxed-mock flow never emits, so the combine would never fire.
|
||||||
|
private fun syncControllerMock(): SyncController =
|
||||||
|
mockk<SyncController>(relaxed = true) {
|
||||||
|
every { lastSyncError } returns MutableStateFlow(null)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initial state is Loading before any DAO emission`() = runTest {
|
fun `initial state is Loading before any DAO emission`() = runTest {
|
||||||
val repo = mockk<LibraryRepository>()
|
val repo = mockk<LibraryRepository>()
|
||||||
@@ -29,7 +37,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +59,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,7 +79,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card's subtitle line (#2377). Wording is kept in lockstep with the web
|
||||||
|
* client's reasonText() in SuggestionFeed.svelte — these assertions are the
|
||||||
|
* record of what that wording IS, so a change on one client without the other
|
||||||
|
* shows up as a failure rather than as silent divergence between the two
|
||||||
|
* surfaces.
|
||||||
|
*/
|
||||||
|
class ArtistSuggestionReasonTest {
|
||||||
|
|
||||||
|
private val seeds = listOf(
|
||||||
|
SeedContributionRef(name = "Seed", isLiked = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun suggestion(
|
||||||
|
matched: List<String> = emptyList(),
|
||||||
|
attribution: List<SeedContributionRef> = seeds,
|
||||||
|
) = ArtistSuggestionRef(
|
||||||
|
mbid = "mb",
|
||||||
|
name = "Candidate",
|
||||||
|
attribution = attribution,
|
||||||
|
matchedTags = matched,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one matched tag reads in the singular`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in shoegaze.",
|
||||||
|
suggestion(matched = listOf("shoegaze")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two matched tags join with and`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in shoegaze and dream pop.",
|
||||||
|
suggestion(matched = listOf("shoegaze", "dream pop")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three matched tags use an Oxford comma`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in a, b, and c.",
|
||||||
|
suggestion(matched = listOf("a", "b", "c")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server caps at 3, but the client must not render a run-on line if a
|
||||||
|
// future server sends more.
|
||||||
|
@Test
|
||||||
|
fun `more than three matched tags are capped at three`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in a, b, and c.",
|
||||||
|
suggestion(matched = listOf("a", "b", "c", "d", "e")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The COMMON case: most candidates have no cached tags (#2376), so the card
|
||||||
|
// must fall back to seed attribution rather than going blank.
|
||||||
|
@Test
|
||||||
|
fun `no matched tags falls back to seed attribution`() {
|
||||||
|
assertEquals("Because you liked Seed.", suggestion().reasonText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to say at all — a candidate with neither tags nor attribution
|
||||||
|
// yields an empty line, which the tile suppresses rather than rendering as
|
||||||
|
// a blank row.
|
||||||
|
@Test
|
||||||
|
fun `neither tags nor attribution yields an empty line`() {
|
||||||
|
assertEquals("", suggestion(attribution = emptyList()).reasonText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The taste reason WINS over attribution when both exist: describing the
|
||||||
|
// music beats describing the similarity graph.
|
||||||
|
@Test
|
||||||
|
fun `a taste match supersedes seed attribution`() {
|
||||||
|
val got = suggestion(matched = listOf("shoegaze")).reasonText
|
||||||
|
assertEquals("Matches your taste in shoegaze.", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `returnsIn` phrasing for the parked-suggestions list (#2375). The clock is
|
||||||
|
* injected rather than frozen, so these assertions are stable.
|
||||||
|
*/
|
||||||
|
class SuggestionSnoozeRefTest {
|
||||||
|
|
||||||
|
private val now = 1_800_000_000_000L // fixed epoch ms; any value works
|
||||||
|
|
||||||
|
private fun snoozeIn(days: Double) = SuggestionSnoozeRef(
|
||||||
|
mbid = "mb",
|
||||||
|
name = "Parked",
|
||||||
|
snoozedUntil = Instant
|
||||||
|
.fromEpochMilliseconds(now + (days * 86_400_000L).toLong())
|
||||||
|
.toString(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the default 90-day snooze reads as about 3 months`() {
|
||||||
|
assertEquals("in about 3 months", snoozeIn(90.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a month reads in the singular`() {
|
||||||
|
assertEquals("in about a month", snoozeIn(30.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `under the month threshold it counts days`() {
|
||||||
|
assertEquals("in 14 days", snoozeIn(14.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pins the days→months boundary. The singular branch was originally dead
|
||||||
|
// code because the threshold (45) sat above the divisor (30), so no day
|
||||||
|
// count could ever round to one month without being caught by the days
|
||||||
|
// branch first. Asserting both sides of the seam keeps that from
|
||||||
|
// regressing silently.
|
||||||
|
@Test
|
||||||
|
fun `the days-to-months boundary is exactly at 30 days`() {
|
||||||
|
assertEquals("in 29 days", snoozeIn(29.0).returnsIn(now))
|
||||||
|
assertEquals("in about a month", snoozeIn(30.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `well past a month still reads in the singular rather than jumping to two`() {
|
||||||
|
assertEquals("in about a month", snoozeIn(40.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tomorrow is named, not rendered as 1 days`() {
|
||||||
|
assertEquals("tomorrow", snoozeIn(1.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `later today rounds down to today rather than going negative`() {
|
||||||
|
assertEquals("today", snoozeIn(0.1).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server only ever returns unexpired rows, so a past timestamp means
|
||||||
|
// our clock and the server's disagree. The row is on screen either way, so
|
||||||
|
// say something plausible rather than leaving the line blank.
|
||||||
|
@Test
|
||||||
|
fun `an already-past expiry degrades to shortly`() {
|
||||||
|
assertEquals("shortly", snoozeIn(-5.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unparseable timestamp degrades to shortly`() {
|
||||||
|
val row = SuggestionSnoozeRef(mbid = "mb", name = "Parked", snoozedUntil = "not-a-date")
|
||||||
|
assertEquals("shortly", row.returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty timestamp degrades to shortly`() {
|
||||||
|
val row = SuggestionSnoozeRef(mbid = "mb", name = "Parked", snoozedUntil = "")
|
||||||
|
assertEquals("shortly", row.returnsIn(now))
|
||||||
|
}
|
||||||
|
}
|
||||||
+314
@@ -0,0 +1,314 @@
|
|||||||
|
package com.fabledsword.minstrel.shared
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
private const val WORK_MS = 1_000L
|
||||||
|
private const val CHURN_ROUNDS = 5
|
||||||
|
private const val ART_IN_FLIGHT = 3
|
||||||
|
private const val SUCCEED_ON_ATTEMPT = 3
|
||||||
|
private const val QUIET_WINDOWS_TO_OUTLAST = 3
|
||||||
|
|
||||||
|
// Time to let a session finish once the screen has stopped changing: the
|
||||||
|
// retry backoffs, the quiet window and the minimum hold all fit inside it,
|
||||||
|
// while staying well under maxHoldMs. That gap matters — if a drain ran
|
||||||
|
// past the ceiling, "the veil lowered" would no longer distinguish
|
||||||
|
// "it settled" from "it gave up", which is the whole point of these tests.
|
||||||
|
private const val DRAIN_MS = 3_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The veil's job is to go up only when content actually changes, and then to
|
||||||
|
* stay up until the screen has stopped moving. Each test pins one of the ways
|
||||||
|
* the original fixed-delay implementation got that wrong (issue #2327).
|
||||||
|
*
|
||||||
|
* The controller is built on `backgroundScope` throughout: its consumer
|
||||||
|
* loop runs forever, so hanging it off the test's own scope would stop
|
||||||
|
* `runTest` from ever completing.
|
||||||
|
*
|
||||||
|
* Consequence, and the reason every wait below is an explicit
|
||||||
|
* `advanceTimeBy`: **`advanceUntilIdle()` is useless here.** It advances
|
||||||
|
* only while *foreground* work remains, and everything this controller
|
||||||
|
* does lives in `backgroundScope` — so it returns having run nothing, and
|
||||||
|
* assertions land on a session that never started (CI run 3163 failed all
|
||||||
|
* seven of these with "expected 3, actual 0" and friends). Drive the clock
|
||||||
|
* deliberately instead; don't "simplify" these back to advanceUntilIdle.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class UpdateVeilControllerTest {
|
||||||
|
|
||||||
|
private val timings = VeilTimings(
|
||||||
|
minHoldMs = 900,
|
||||||
|
quietMs = 700,
|
||||||
|
maxHoldMs = 12_000,
|
||||||
|
attempts = 3,
|
||||||
|
retryBackoffMs = 600,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Drives the settle signal by hand: content key, presence, art count. */
|
||||||
|
private class FakeScreen(hasContent: Boolean = true) {
|
||||||
|
val state = MutableStateFlow(Triple(0, hasContent, 0))
|
||||||
|
|
||||||
|
val signal = state.map { (key, hasContent, art) ->
|
||||||
|
VeilSettleState(contentKey = key, hasContent = hasContent, quiescent = art == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content visibly changed — what the veil exists to cover. */
|
||||||
|
fun churn() {
|
||||||
|
state.value = state.value.copy(first = state.value.first + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun artLoading(count: Int) {
|
||||||
|
state.value = state.value.copy(third = count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contentAppears() {
|
||||||
|
state.value = state.value.copy(second = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.controllerOn(
|
||||||
|
screen: FakeScreen,
|
||||||
|
shouldVeil: suspend () -> Boolean = { true },
|
||||||
|
onSessionEnd: (VeilSessionResult) -> Unit = {},
|
||||||
|
work: suspend () -> Boolean,
|
||||||
|
) = UpdateVeilController(
|
||||||
|
scope = backgroundScope,
|
||||||
|
settleSignal = screen.signal,
|
||||||
|
shouldVeil = shouldVeil,
|
||||||
|
timings = timings,
|
||||||
|
onSessionEnd = onSessionEnd,
|
||||||
|
work = work,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Records every visibility transition, so an extra raise can't hide. */
|
||||||
|
private fun TestScope.recordVisibility(controller: UpdateVeilController): List<Boolean> {
|
||||||
|
val seen = mutableListOf<Boolean>()
|
||||||
|
backgroundScope.launch { controller.visible.collect { seen.add(it) } }
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `veil outlasts content that keeps churning after the pull returns`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val controller = controllerOn(screen) { true }
|
||||||
|
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
// The pull's write lands: content changed, so the veil goes up.
|
||||||
|
screen.churn()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(controller.visible.value, "veil is up while the screen is still moving")
|
||||||
|
|
||||||
|
// Tiles hydrating one after another, each inside the quiet window.
|
||||||
|
// The old implementation had already wiped off after a flat 500ms.
|
||||||
|
repeat(CHURN_ROUNDS) {
|
||||||
|
advanceTimeBy(timings.quietMs / 2)
|
||||||
|
screen.churn()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(controller.visible.value, "veil must hold across staged churn")
|
||||||
|
}
|
||||||
|
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertFalse(controller.visible.value, "veil lowers once the screen goes quiet")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `veil waits for artwork to finish loading`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val controller = controllerOn(screen) { true }
|
||||||
|
|
||||||
|
screen.artLoading(ART_IN_FLIGHT)
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
screen.churn()
|
||||||
|
runCurrent()
|
||||||
|
// Well past the quiet window and the floor — but art is still in
|
||||||
|
// flight, so lowering now would show the covers popping in.
|
||||||
|
advanceTimeBy(timings.minHoldMs + timings.quietMs * QUIET_WINDOWS_TO_OUTLAST)
|
||||||
|
assertTrue(controller.visible.value, "veil must wait on in-flight art")
|
||||||
|
|
||||||
|
screen.artLoading(0)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertFalse(controller.visible.value, "veil lowers once art has landed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retries quietly, then veils the churn the successful attempt produces`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
var attempts = 0
|
||||||
|
val controller = controllerOn(screen) {
|
||||||
|
attempts++
|
||||||
|
val succeeded = attempts >= SUCCEED_ON_ATTEMPT // fail twice
|
||||||
|
// Only a pull that worked writes anything.
|
||||||
|
if (succeeded) screen.churn()
|
||||||
|
succeeded
|
||||||
|
}
|
||||||
|
val seen = recordVisibility(controller)
|
||||||
|
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
// A failed pull changes nothing, so there is nothing to hide yet —
|
||||||
|
// the retries happen with no veil at all.
|
||||||
|
assertFalse(controller.visible.value, "no veil over a pull that changed nothing")
|
||||||
|
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(SUCCEED_ON_ATTEMPT, attempts, "retries until the pull succeeds")
|
||||||
|
assertEquals(
|
||||||
|
listOf(false, true, false),
|
||||||
|
seen,
|
||||||
|
"the veil went up once, over the churn the retry finally produced",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `giving up is silent, reports FAILED, and does not block later requests`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
var attempts = 0
|
||||||
|
var succeed = false
|
||||||
|
val controller = controllerOn(screen, onSessionEnd = { results += it }) {
|
||||||
|
attempts++
|
||||||
|
succeed
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(timings.attempts, attempts, "exhausts its attempts")
|
||||||
|
assertFalse(controller.visible.value, "no veil — a failed pull changed nothing")
|
||||||
|
assertEquals(VeilOutcome.FAILED, results.single().outcome)
|
||||||
|
assertTrue(results.single().userInitiated, "the user asked, so they're owed an answer")
|
||||||
|
|
||||||
|
// Giving up must not latch anything off — the reconnect-driven
|
||||||
|
// recovery still gets to try again later.
|
||||||
|
succeed = true
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(timings.attempts + 1, attempts, "a later request still runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overlapping requests extend one veil instead of racing it`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
var started = 0
|
||||||
|
val controller = controllerOn(screen) {
|
||||||
|
started++
|
||||||
|
delay(WORK_MS)
|
||||||
|
screen.churn()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
val seen = recordVisibility(controller)
|
||||||
|
|
||||||
|
// Reconnect and the rebuild event arriving together is what made the
|
||||||
|
// old Boolean flag clear mid-update: whichever pull finished first
|
||||||
|
// wiped the veil off while the other was still running.
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(WORK_MS + 1)
|
||||||
|
assertTrue(controller.visible.value, "second trigger extends the same veil")
|
||||||
|
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(2, started, "the mid-session trigger still did its pull")
|
||||||
|
assertEquals(listOf(false, true, false), seen, "one veil session, not two")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a never-settling screen still releases the veil at the ceiling`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val controller = controllerOn(screen) {
|
||||||
|
screen.churn()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.artLoading(1) // an image that never completes
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(timings.maxHoldMs + 1)
|
||||||
|
assertFalse(controller.visible.value, "the hard ceiling must never strand the user")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unchanged refresh never raises the veil and reports UNCHANGED`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
// Succeeds without writing anything — the common case on a launch
|
||||||
|
// over a warm cache, where the server returns what's already cached.
|
||||||
|
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
|
||||||
|
val seen = recordVisibility(controller)
|
||||||
|
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertEquals(listOf(false), seen, "a veil over an unchanged screen would hide nothing")
|
||||||
|
assertEquals(VeilOutcome.UNCHANGED, results.single().outcome)
|
||||||
|
assertTrue(results.single().userInitiated, "so the caller can say 'already up to date'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cached content painting is not mistaken for a change`() = runTest {
|
||||||
|
// Warm cache that hasn't painted yet: the rows arrive a moment after
|
||||||
|
// the session starts. Treating that first paint as churn would veil
|
||||||
|
// every single launch.
|
||||||
|
val screen = FakeScreen(hasContent = false)
|
||||||
|
val controller = controllerOn(screen) { true }
|
||||||
|
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
screen.contentAppears()
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertFalse(controller.visible.value, "first paint is not churn")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a background trigger coalescing with the user's does not swallow their answer`() =
|
||||||
|
runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
|
||||||
|
|
||||||
|
// Conflation drops the older token, so the "a user asked" bit
|
||||||
|
// cannot ride on it — it's tracked separately for exactly this.
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
results.first().userInitiated,
|
||||||
|
"the user's request must not be conflated away",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cold load runs unveiled and is never reported as already up to date`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
var ran = false
|
||||||
|
val controller = controllerOn(
|
||||||
|
screen,
|
||||||
|
shouldVeil = { false }, // empty cache: the skeleton owns this
|
||||||
|
onSessionEnd = { results += it },
|
||||||
|
) {
|
||||||
|
ran = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertTrue(ran, "the refresh still happens")
|
||||||
|
assertFalse(controller.visible.value, "but no veil over a skeleton")
|
||||||
|
// It went from nothing to something — that IS a change.
|
||||||
|
assertEquals(VeilOutcome.CHANGED, results.single().outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-11
@@ -9,11 +9,17 @@ Minstrel's four workflows consume two CI images:
|
|||||||
|
|
||||||
```
|
```
|
||||||
git.fabledsword.com/bvandeusen/ci-go:1.26
|
git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
git.fabledsword.com/bvandeusen/ci-android:36
|
||||||
```
|
```
|
||||||
|
|
||||||
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`.gitea/workflows/release.yml`).
|
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job).
|
||||||
- `ci-flutter:3.44` — Flutter client tests + debug/release APK builds (`.gitea/workflows/flutter.yml`).
|
- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job).
|
||||||
|
|
||||||
|
**`ci-flutter` is no longer consumed.** The M8 rewrite replaced the Flutter
|
||||||
|
client with the native Android app and `flutter.yml` was removed; `ci-android`
|
||||||
|
took its place. `flutter_client/` is still in the tree but nothing builds it.
|
||||||
|
CI-Runner still publishes `ci-flutter` and will retire it once that directory
|
||||||
|
goes — so if the Flutter client is ever revived, say so there first.
|
||||||
|
|
||||||
## Image deps used
|
## Image deps used
|
||||||
|
|
||||||
@@ -25,13 +31,20 @@ git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
|||||||
- **docker buildx** — release container build + push in `release.yml`.
|
- **docker buildx** — release container build + push in `release.yml`.
|
||||||
- **curl** — release-asset polling / upload in `release.yml`.
|
- **curl** — release-asset polling / upload in `release.yml`.
|
||||||
|
|
||||||
### From `ci-flutter:3.44`
|
### From `ci-android:36`
|
||||||
- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release).
|
- **JDK 25** — Gradle launcher + Android build. Requires Gradle 9.1.0+ in
|
||||||
- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen).
|
`android/gradle/wrapper`; older Gradle rejects JDK 25 with an opaque `"25.0.3"`
|
||||||
- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing.
|
error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED`
|
||||||
- **Java 25** — Gradle / Android build.
|
to silence Gradle's launcher-JVM restricted-method warning.
|
||||||
|
- **Android SDK + cmdline-tools + build-tools 36.0.0** — APK assembly + signing.
|
||||||
|
No NDK: the native client has no C/C++ sources (this is why it isn't on
|
||||||
|
`ci-flutter`).
|
||||||
|
- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in
|
||||||
|
`android.yml`. Image pins track `android/gradle/libs.versions.toml` so local
|
||||||
|
and CI checks agree.
|
||||||
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
||||||
- **base64 + curl** — keystore decode + release-asset upload in the tag-build path.
|
- **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s
|
||||||
|
`android-release` job.
|
||||||
|
|
||||||
## Per-job tool installs
|
## Per-job tool installs
|
||||||
|
|
||||||
@@ -39,9 +52,69 @@ None.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions.
|
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way.
|
||||||
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
||||||
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
||||||
- **In-app update channel polling.** `release.yml` polls Gitea's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure).
|
- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
|
||||||
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
||||||
|
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
|
||||||
|
```yaml
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||||
|
```
|
||||||
|
Upstream's `@v4+` cannot work against this instance and no server-side change
|
||||||
|
will help: `isGhes()` rejects any hostname that isn't `github.com` /
|
||||||
|
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
|
||||||
|
server is never asked what it supports. `@v3` is worse — it reports success,
|
||||||
|
and Gitea then serves artifacts back only through the v4 API
|
||||||
|
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
|
||||||
|
to every retrieval path. A green job producing nothing retrievable; that is how
|
||||||
|
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
|
||||||
|
|
||||||
|
Both are pull mirrors of the Forgejo project's forks
|
||||||
|
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
|
||||||
|
disabling that check), mirrored so CI depends on commits we hold and pinned by
|
||||||
|
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
|
||||||
|
otherwise silently change what runs.
|
||||||
|
|
||||||
|
**Match the pins on `@actions/artifact`, not on the actions' own version
|
||||||
|
numbers.** The two actions release on unrelated cadences, so equal version
|
||||||
|
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
|
||||||
|
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
|
||||||
|
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
|
||||||
|
matters because `release.yml` is a producer/consumer pair — `android-release`
|
||||||
|
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
|
||||||
|
across it yields an empty listing rather than an error, exactly the silent
|
||||||
|
failure this entry exists to prevent.
|
||||||
|
|
||||||
|
| tag | `@actions/artifact` | runtime |
|
||||||
|
|---|---|---|
|
||||||
|
| upload v4 | ^2.1.1 | node20 |
|
||||||
|
| **upload v5** ← pinned | **^4.0.0** | node20 |
|
||||||
|
| download v4 | ^2.1.1 | node20 |
|
||||||
|
| download v5 | ^2.3.2 | node20 |
|
||||||
|
| **download v6** ← pinned | **^4.0.0** | node20 |
|
||||||
|
| download v7 | ^5.0.0 | **node24** |
|
||||||
|
|
||||||
|
The only true protocol break in this history was **v3 → v4** (upstream:
|
||||||
|
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
|
||||||
|
below are not supported"); v4-and-up are one family. Later majors are mostly
|
||||||
|
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
|
||||||
|
at 500 artifacts; download v5 made by-ID extraction match by-name.
|
||||||
|
|
||||||
|
**Do not jump the download pin to v7.** That major is a runner requirement, not
|
||||||
|
a feature change: it moves to `runs.using: node24` and upstream states it
|
||||||
|
"requires a minimum Actions Runner version of 2.327.1 … if you are using
|
||||||
|
self-hosted runners, ensure they are updated before upgrading." act_runner is
|
||||||
|
not GitHub's runner and makes no such version claim, so node24 is unverified
|
||||||
|
here. Everything currently pinned is node20.
|
||||||
|
|
||||||
|
Upload steps set `if-no-files-found: error` rather than the default `warn`, so
|
||||||
|
an upload that matches nothing fails its own job instead of failing the
|
||||||
|
consumer later.
|
||||||
|
|
||||||
|
Retrieval: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
|
||||||
|
for the id (global run id, not the repo-scoped run number), then
|
||||||
|
`…/actions/artifacts/{id}/zip`. The workstation has no `unzip` — use
|
||||||
|
`python3 -m zipfile -e`.
|
||||||
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
|
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||||
@@ -25,12 +26,14 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -146,6 +149,16 @@ func run() error {
|
|||||||
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
||||||
coverEnricher.DataDir = cfg.Storage.DataDir
|
coverEnricher.DataDir = cfg.Storage.DataDir
|
||||||
|
|
||||||
|
// Recommendation tuning lab (#1250): seeds shipped defaults on first
|
||||||
|
// boot and pushes the daily_mix weights + taste config into package
|
||||||
|
// playlists — must precede the scheduler so the first builds score
|
||||||
|
// with the operator's tuned values, not the pre-push literals.
|
||||||
|
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("recommendation settings service init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
||||||
// Boot-time scan and manual-trigger scans share this path; results land
|
// Boot-time scan and manual-trigger scans share this path; results land
|
||||||
// in scan_runs for the admin overview.
|
// in scan_runs for the admin overview.
|
||||||
@@ -194,6 +207,31 @@ func run() error {
|
|||||||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||||||
go similarityWorker.Run(ctx)
|
go similarityWorker.Run(ctx)
|
||||||
|
|
||||||
|
// Start the household co-play worker (#1533). Recomputes artist–artist
|
||||||
|
// co-occurrence edges (source='user_cooccurrence') from play_events every
|
||||||
|
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
|
||||||
|
// SQL, no external calls; empty on single-user servers.
|
||||||
|
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
|
||||||
|
|
||||||
|
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
||||||
|
// tag providers with tag_provider_settings, bumps the sources version if
|
||||||
|
// the provider set changed (re-opening settled rows), then drains tracks
|
||||||
|
// needing folksonomy tags on a periodic tick. Standalone (not in the file
|
||||||
|
// scan chain) because tag lookups need only DB fields — MBID / artist /
|
||||||
|
// title — that a scan has already imported.
|
||||||
|
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("tag settings service init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
|
||||||
|
logger.Warn("tags: provider-hash boot check failed", "err", berr)
|
||||||
|
} else if bumped {
|
||||||
|
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
|
||||||
|
}
|
||||||
|
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
|
||||||
|
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
|
||||||
|
|
||||||
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
||||||
// that have no writer-side close path or retention policy:
|
// that have no writer-side close path or retention policy:
|
||||||
// orphan play_events, stale play_sessions, expired
|
// orphan play_events, stale play_sessions, expired
|
||||||
@@ -294,6 +332,8 @@ func run() error {
|
|||||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||||||
srv.Bus = bus
|
srv.Bus = bus
|
||||||
srv.PlaylistScheduler = playlistScheduler
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
|
srv.RecSettings = recSettings
|
||||||
|
srv.TagSettings = tagSettings
|
||||||
srv.StreamSecret = cfg.StreamSecret
|
srv.StreamSecret = cfg.StreamSecret
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Admin recommendation-trends endpoint (#1251): weekly per-surface
|
||||||
|
// outcome series with tuning-audit markers — the verify half of the
|
||||||
|
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
|
||||||
|
// all users because the knobs are global; rows carry rates only.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
trendsDefaultWeeks = 12
|
||||||
|
trendsMaxWeeks = 52
|
||||||
|
// trendsAuditFetchCap bounds the audit fetch; markers older than
|
||||||
|
// the window are dropped in Go. Far above any real knob-turn count
|
||||||
|
// inside a year.
|
||||||
|
trendsAuditFetchCap = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// trendPoint is one week of one surface family's outcomes.
|
||||||
|
type trendPoint struct {
|
||||||
|
WeekStart string `json:"week_start"` // ISO date (Monday)
|
||||||
|
Plays int64 `json:"plays"`
|
||||||
|
Skips int64 `json:"skips"`
|
||||||
|
SkipRate float64 `json:"skip_rate"`
|
||||||
|
AvgCompletion float64 `json:"avg_completion"`
|
||||||
|
// TasteHitRate is the share of plays whose track's artist carries a
|
||||||
|
// positive weight in the player's current taste profile — a drifted
|
||||||
|
// but retroactive read on whether the surface feeds taste-fitting
|
||||||
|
// tracks.
|
||||||
|
TasteHitRate float64 `json:"taste_hit_rate"`
|
||||||
|
// completionN carries the completion sample count through same-week
|
||||||
|
// merges so avg_completion stays sample-weighted; not serialized.
|
||||||
|
completionN int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// trendSeries is one surface family's weekly series.
|
||||||
|
type trendSeries struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
|
||||||
|
Plays int64 `json:"plays"` // window total, for sorting/volume-gating
|
||||||
|
Points []trendPoint `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// trendMarker is one tuning-audit event annotated on the timeline.
|
||||||
|
type trendMarker struct {
|
||||||
|
ChangedAt string `json:"changed_at"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Changes json.RawMessage `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type trendsResp struct {
|
||||||
|
Weeks int `json:"weeks"`
|
||||||
|
Series []trendSeries `json:"series"`
|
||||||
|
Markers []trendMarker `json:"markers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationTrends implements
|
||||||
|
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
|
||||||
|
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
|
||||||
|
weeks := trendsDefaultWeeks
|
||||||
|
if v := r.URL.Query().Get("weeks"); v != "" {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > trendsMaxWeeks {
|
||||||
|
n = trendsMaxWeeks
|
||||||
|
}
|
||||||
|
weeks = n
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: recommendation trends", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: recommendation trends audit", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTrendsResponse folds weekly rows into per-family series and
|
||||||
|
// windows the audit markers. Split from the handler for pure-unit
|
||||||
|
// testability.
|
||||||
|
func buildTrendsResponse(
|
||||||
|
weeks int, now time.Time,
|
||||||
|
rows []dbq.RecommendationWeeklyTrendsRow,
|
||||||
|
audits []dbq.RecommendationTuningAudit,
|
||||||
|
) trendsResp {
|
||||||
|
type accum struct {
|
||||||
|
fam recFamily
|
||||||
|
plays int64
|
||||||
|
points []trendPoint
|
||||||
|
}
|
||||||
|
families := map[string]*accum{}
|
||||||
|
for _, row := range rows {
|
||||||
|
fam := recFamily{key: "manual", label: "Manual library plays"}
|
||||||
|
if row.Source != nil && *row.Source != "" {
|
||||||
|
fam = bucketRecSource(*row.Source)
|
||||||
|
}
|
||||||
|
acc, ok := families[fam.key]
|
||||||
|
if !ok {
|
||||||
|
acc = &accum{fam: fam}
|
||||||
|
families[fam.key] = acc
|
||||||
|
}
|
||||||
|
acc.plays += row.Plays
|
||||||
|
p := trendPoint{
|
||||||
|
WeekStart: row.WeekStart.Time.Format("2006-01-02"),
|
||||||
|
Plays: row.Plays,
|
||||||
|
Skips: row.Skips,
|
||||||
|
AvgCompletion: row.AvgCompletion,
|
||||||
|
completionN: row.CompletionN,
|
||||||
|
}
|
||||||
|
if row.Plays > 0 {
|
||||||
|
p.SkipRate = float64(row.Skips) / float64(row.Plays)
|
||||||
|
p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
|
||||||
|
}
|
||||||
|
// Same family can arrive as several raw sources (radio:<uuid>);
|
||||||
|
// merge same-week points play-weighted.
|
||||||
|
if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
|
||||||
|
acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
|
||||||
|
} else {
|
||||||
|
acc.points = append(acc.points, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
|
||||||
|
for _, acc := range families {
|
||||||
|
resp.Series = append(resp.Series, trendSeries{
|
||||||
|
Key: acc.fam.key,
|
||||||
|
Label: acc.fam.label,
|
||||||
|
Intent: acc.fam.intent,
|
||||||
|
Plays: acc.plays,
|
||||||
|
Points: acc.points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(resp.Series, func(i, j int) bool {
|
||||||
|
if resp.Series[i].Plays != resp.Series[j].Plays {
|
||||||
|
return resp.Series[i].Plays > resp.Series[j].Plays
|
||||||
|
}
|
||||||
|
return resp.Series[i].Key < resp.Series[j].Key
|
||||||
|
})
|
||||||
|
|
||||||
|
cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
|
||||||
|
for _, a := range audits {
|
||||||
|
if a.ChangedAt.Time.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Markers = append(resp.Markers, trendMarker{
|
||||||
|
ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
|
||||||
|
Scope: a.Scope,
|
||||||
|
Action: a.Action,
|
||||||
|
Changes: json.RawMessage(a.Changes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// ListTuningAudit returns newest-first; the timeline reads better
|
||||||
|
// oldest-first.
|
||||||
|
sort.Slice(resp.Markers, func(i, j int) bool {
|
||||||
|
return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
|
||||||
|
})
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeTrendPoints combines two same-week points of one family:
|
||||||
|
// counts add, skip/taste rates re-derive from the merged counts, and
|
||||||
|
// avg_completion is weighted by each side's completion sample count.
|
||||||
|
func mergeTrendPoints(a, b trendPoint) trendPoint {
|
||||||
|
out := trendPoint{
|
||||||
|
WeekStart: a.WeekStart,
|
||||||
|
Plays: a.Plays + b.Plays,
|
||||||
|
Skips: a.Skips + b.Skips,
|
||||||
|
completionN: a.completionN + b.completionN,
|
||||||
|
}
|
||||||
|
if out.Plays > 0 {
|
||||||
|
out.SkipRate = float64(out.Skips) / float64(out.Plays)
|
||||||
|
out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
|
||||||
|
float64(out.Plays)
|
||||||
|
}
|
||||||
|
if out.completionN > 0 {
|
||||||
|
out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
|
||||||
|
float64(out.completionN)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func date(s string) pgtype.Date {
|
||||||
|
t, _ := time.Parse("2006-01-02", s)
|
||||||
|
return pgtype.Date{Time: t, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTrendsResponse_SeriesMergingAndMarkers(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
rows := []dbq.RecommendationWeeklyTrendsRow{
|
||||||
|
// Two radio session sources in the same week collapse into one
|
||||||
|
// family point; completion weighting by sample count.
|
||||||
|
{WeekStart: date("2026-06-22"), Source: src("radio:aaaa"),
|
||||||
|
Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 0.6, TasteHits: 3},
|
||||||
|
{WeekStart: date("2026-06-22"), Source: src("radio:bbbb"),
|
||||||
|
Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2, TasteHits: 0},
|
||||||
|
{WeekStart: date("2026-06-29"), Source: src("radio:aaaa"),
|
||||||
|
Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9, TasteHits: 1},
|
||||||
|
// NULL source = manual baseline family.
|
||||||
|
{WeekStart: date("2026-06-29"), Source: nil,
|
||||||
|
Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.8, TasteHits: 4},
|
||||||
|
}
|
||||||
|
audits := []dbq.RecommendationTuningAudit{
|
||||||
|
{ID: 2, ChangedAt: pgtype.Timestamptz{Time: now.Add(-24 * time.Hour), Valid: true},
|
||||||
|
Scope: "radio", Action: "update", Changes: []byte(`[{"field":"taste_weight","old":1,"new":2}]`)},
|
||||||
|
// Older than the window → dropped.
|
||||||
|
{ID: 1, ChangedAt: pgtype.Timestamptz{Time: now.Add(-100 * 7 * 24 * time.Hour), Valid: true},
|
||||||
|
Scope: "taste", Action: "reset", Changes: []byte(`[]`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildTrendsResponse(12, now, rows, audits)
|
||||||
|
|
||||||
|
if len(resp.Series) != 2 {
|
||||||
|
t.Fatalf("series = %d, want 2 (radio + manual)", len(resp.Series))
|
||||||
|
}
|
||||||
|
radio := resp.Series[0] // 6 plays > manual's 5 → sorted first
|
||||||
|
if radio.Key != "radio" || radio.Plays != 6 {
|
||||||
|
t.Fatalf("series[0] = %s/%d, want radio/6", radio.Key, radio.Plays)
|
||||||
|
}
|
||||||
|
if len(radio.Points) != 2 {
|
||||||
|
t.Fatalf("radio points = %d, want 2 weeks", len(radio.Points))
|
||||||
|
}
|
||||||
|
wk1 := radio.Points[0]
|
||||||
|
if wk1.WeekStart != "2026-06-22" || wk1.Plays != 4 || wk1.Skips != 2 {
|
||||||
|
t.Errorf("week1 = %+v, want 2026-06-22 with 4 plays / 2 skips", wk1)
|
||||||
|
}
|
||||||
|
if wk1.SkipRate != 0.5 {
|
||||||
|
t.Errorf("week1 skip_rate = %v, want 0.5", wk1.SkipRate)
|
||||||
|
}
|
||||||
|
// Completion weighted by sample count: (0.6*3 + 0.2*1) / 4 = 0.5.
|
||||||
|
if wk1.AvgCompletion < 0.49 || wk1.AvgCompletion > 0.51 {
|
||||||
|
t.Errorf("week1 avg_completion = %v, want 0.5", wk1.AvgCompletion)
|
||||||
|
}
|
||||||
|
// Taste hits: 3 of 4 plays.
|
||||||
|
if wk1.TasteHitRate != 0.75 {
|
||||||
|
t.Errorf("week1 taste_hit_rate = %v, want 0.75", wk1.TasteHitRate)
|
||||||
|
}
|
||||||
|
if manual := resp.Series[1]; manual.Key != "manual" || manual.Intent != "" {
|
||||||
|
t.Errorf("series[1] = %+v, want the manual baseline family", manual)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Markers) != 1 {
|
||||||
|
t.Fatalf("markers = %d, want 1 (out-of-window marker dropped)", len(resp.Markers))
|
||||||
|
}
|
||||||
|
if resp.Markers[0].Scope != "radio" || resp.Markers[0].Action != "update" {
|
||||||
|
t.Errorf("marker = %+v, want radio/update", resp.Markers[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTrendsRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/admin/recommendation-trends", h.handleGetRecommendationTrends)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTrends_EndToEnd(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
user := seedUser(t, pool, "trends", "pw", true)
|
||||||
|
artist := seedArtist(t, pool, "TrendArtist")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "TrendAlbum", 2020)
|
||||||
|
tk := seedTrack(t, pool, album.ID, artist.ID, "TrendTrack", 1, 200000)
|
||||||
|
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||||
|
|
||||||
|
// Positive taste weight for the artist → plays count as taste hits.
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 1.5)`,
|
||||||
|
user.ID, artist.ID); err != nil {
|
||||||
|
t.Fatalf("seed taste profile: %v", err)
|
||||||
|
}
|
||||||
|
radio := "radio"
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radio, nil, 0.9, false)
|
||||||
|
// A knob turn to mark the timeline.
|
||||||
|
if err := h.recSettings.UpdateProfile(context.Background(), "radio",
|
||||||
|
map[string]float64{"taste_weight": 2}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTrendsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp trendsResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Weeks != trendsDefaultWeeks {
|
||||||
|
t.Errorf("weeks = %d, want %d", resp.Weeks, trendsDefaultWeeks)
|
||||||
|
}
|
||||||
|
var radioSeries *trendSeries
|
||||||
|
for i := range resp.Series {
|
||||||
|
if resp.Series[i].Key == "radio" {
|
||||||
|
radioSeries = &resp.Series[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if radioSeries == nil || len(radioSeries.Points) != 1 {
|
||||||
|
t.Fatalf("radio series = %+v, want one point", radioSeries)
|
||||||
|
}
|
||||||
|
if radioSeries.Points[0].TasteHitRate != 1.0 {
|
||||||
|
t.Errorf("taste_hit_rate = %v, want 1.0 (positive-weight artist)",
|
||||||
|
radioSeries.Points[0].TasteHitRate)
|
||||||
|
}
|
||||||
|
if len(resp.Markers) != 1 || resp.Markers[0].Scope != "radio" {
|
||||||
|
t.Errorf("markers = %+v, want the radio knob turn", resp.Markers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTrends_InvalidWeeks(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends?weeks=zero", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTrendsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Admin recommendation-tuning endpoints (#1250): the defaults-
|
||||||
|
// discovery lab. GET returns current values + shipped defaults for
|
||||||
|
// every scope; PATCH applies a partial update to one scope; reset
|
||||||
|
// restores a scope to shipped defaults. Every change writes an audit
|
||||||
|
// row (consumed by the metrics trend view, #1251).
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// weightsResp is one weight profile on the wire, keyed by the same
|
||||||
|
// snake_case field names the PATCH body accepts.
|
||||||
|
type weightsResp struct {
|
||||||
|
BaseWeight float64 `json:"base_weight"`
|
||||||
|
LikeBoost float64 `json:"like_boost"`
|
||||||
|
RecencyWeight float64 `json:"recency_weight"`
|
||||||
|
SkipPenalty float64 `json:"skip_penalty"`
|
||||||
|
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||||
|
ContextWeight float64 `json:"context_weight"`
|
||||||
|
SimilarityWeight float64 `json:"similarity_weight"`
|
||||||
|
TasteWeight float64 `json:"taste_weight"`
|
||||||
|
ContextTimeWeight float64 `json:"context_time_weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
||||||
|
return weightsResp{
|
||||||
|
BaseWeight: w.BaseWeight,
|
||||||
|
LikeBoost: w.LikeBoost,
|
||||||
|
RecencyWeight: w.RecencyWeight,
|
||||||
|
SkipPenalty: w.SkipPenalty,
|
||||||
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
|
ContextWeight: w.ContextWeight,
|
||||||
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
|
TasteWeight: w.TasteWeight,
|
||||||
|
ContextTimeWeight: w.ContextTimeWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tasteTuningResp struct {
|
||||||
|
HalfLifeDays float64 `json:"half_life_days"`
|
||||||
|
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||||
|
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||||
|
EngagementFull float64 `json:"engagement_full"`
|
||||||
|
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
||||||
|
EraScale float64 `json:"era_scale"`
|
||||||
|
MoodScale float64 `json:"mood_scale"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||||
|
return tasteTuningResp{
|
||||||
|
HalfLifeDays: t.HalfLifeDays,
|
||||||
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
|
EngagementFull: t.EngagementFull,
|
||||||
|
EnrichedTagScale: t.EnrichedTagScale,
|
||||||
|
EraScale: t.EraScale,
|
||||||
|
MoodScale: t.MoodScale,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverTuningResp is the Discover scope on the wire (#2377).
|
||||||
|
type discoverTuningResp struct {
|
||||||
|
TagOverlapWeight float64 `json:"tag_overlap_weight"`
|
||||||
|
SnoozeDays float64 `json:"snooze_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverRespFrom(d recsettings.DiscoverTuning) discoverTuningResp {
|
||||||
|
return discoverTuningResp{
|
||||||
|
TagOverlapWeight: d.TagOverlapWeight,
|
||||||
|
SnoozeDays: d.SnoozeDays,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuningSnapshot is both the GET response and the post-mutation echo:
|
||||||
|
// current values alongside shipped defaults so the card can mark
|
||||||
|
// which knobs deviate.
|
||||||
|
type tuningSnapshot struct {
|
||||||
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Discover discoverTuningResp `json:"discover"`
|
||||||
|
Shipped struct {
|
||||||
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Discover discoverTuningResp `json:"discover"`
|
||||||
|
} `json:"shipped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) tuningSnapshot() tuningSnapshot {
|
||||||
|
var out tuningSnapshot
|
||||||
|
out.Profiles = map[string]weightsResp{
|
||||||
|
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
|
||||||
|
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||||
|
}
|
||||||
|
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||||
|
out.Discover = discoverRespFrom(h.recSettings.Discover())
|
||||||
|
out.Shipped.Profiles = map[string]weightsResp{
|
||||||
|
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||||
|
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||||
|
}
|
||||||
|
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||||
|
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
|
||||||
|
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchTuningReq carries the partial update: field name → new value,
|
||||||
|
// using the same snake_case names the GET response emits.
|
||||||
|
type patchTuningReq struct {
|
||||||
|
Values map[string]float64 `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePatchRecommendationTuning implements
|
||||||
|
// PATCH /api/admin/recommendation-tuning/{scope}.
|
||||||
|
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := chi.URLParam(r, "scope")
|
||||||
|
var body patchTuningReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Values) == 0 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
switch scope {
|
||||||
|
case recsettings.ScopeTaste:
|
||||||
|
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
||||||
|
case recsettings.ScopeDiscover:
|
||||||
|
err = h.recSettings.UpdateDiscover(r.Context(), body.Values)
|
||||||
|
default:
|
||||||
|
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeTuningErr(w, h, scope, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetRecommendationTuning implements
|
||||||
|
// POST /api/admin/recommendation-tuning/{scope}/reset.
|
||||||
|
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := chi.URLParam(r, "scope")
|
||||||
|
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
|
||||||
|
writeTuningErr(w, h, scope, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTuningErr maps recsettings validation errors to 400s and
|
||||||
|
// everything else to a logged 500.
|
||||||
|
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, recsettings.ErrUnknownScope):
|
||||||
|
writeErr(w, &apierror.Error{
|
||||||
|
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
|
||||||
|
})
|
||||||
|
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
|
||||||
|
default:
|
||||||
|
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("tuning update failed", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTuningRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||||
|
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||||
|
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
|
||||||
|
t.Helper()
|
||||||
|
var snap tuningSnapshot
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTuningRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
snap := decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
|
||||||
|
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
|
||||||
|
}
|
||||||
|
if snap.Taste.HalfLifeDays != 75 {
|
||||||
|
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
|
||||||
|
}
|
||||||
|
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
|
||||||
|
t.Error("untouched values must equal shipped defaults")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
r := newTuningRouter(h)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
|
||||||
|
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
snap := decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 2.5 {
|
||||||
|
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
|
||||||
|
}
|
||||||
|
// The change is live for the radio scoring path.
|
||||||
|
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
|
||||||
|
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("reset status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
snap = decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 1.0 {
|
||||||
|
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_PatchErrors(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
r := newTuningRouter(h)
|
||||||
|
cases := []struct {
|
||||||
|
name, path, body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"unknown scope", "/api/admin/recommendation-tuning/banana",
|
||||||
|
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
|
||||||
|
{"unknown field", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{"values":{"vibes":1}}`, http.StatusBadRequest},
|
||||||
|
{"out of range", "/api/admin/recommendation-tuning/taste",
|
||||||
|
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
|
||||||
|
{"empty values", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{"values":{}}`, http.StatusBadRequest},
|
||||||
|
{"bad json", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{`, http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != c.want {
|
||||||
|
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagSourceResp is the wire shape for one tag-enrichment provider in the
|
||||||
|
// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface
|
||||||
|
// (admin_cover_sources.go) — a separate, independent settings card so a new
|
||||||
|
// tag source is added without touching art settings (#1490).
|
||||||
|
type tagSourceResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
RequiresAPIKey bool `json:"requires_api_key"`
|
||||||
|
Supports []string `json:"supports"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
APIKeySet bool `json:"api_key_set"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
|
Testable bool `json:"testable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagSourceRespFrom(info tags.ProviderInfo) tagSourceResp {
|
||||||
|
supports := info.Supports
|
||||||
|
if supports == nil {
|
||||||
|
supports = []string{}
|
||||||
|
}
|
||||||
|
return tagSourceResp{
|
||||||
|
ID: info.ID,
|
||||||
|
DisplayName: info.DisplayName,
|
||||||
|
RequiresAPIKey: info.RequiresAPIKey,
|
||||||
|
Supports: supports,
|
||||||
|
Enabled: info.Enabled,
|
||||||
|
APIKeySet: info.APIKeySet,
|
||||||
|
DisplayOrder: info.DisplayOrder,
|
||||||
|
Testable: info.Testable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagSourcesListResp struct {
|
||||||
|
Providers []tagSourceResp `json:"providers"`
|
||||||
|
SourcesVersion int32 `json:"sources_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListTagSources implements GET /api/admin/tag-sources.
|
||||||
|
func (h *handlers) handleListTagSources(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
infos := h.tagSettings.ListProviderInfo()
|
||||||
|
out := tagSourcesListResp{
|
||||||
|
Providers: make([]tagSourceResp, 0, len(infos)),
|
||||||
|
SourcesVersion: h.tagSettings.CurrentVersion(),
|
||||||
|
}
|
||||||
|
for _, info := range infos {
|
||||||
|
out.Providers = append(out.Providers, tagSourceRespFrom(info))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTagSourceReq is the PATCH body. nil pointers mean "leave
|
||||||
|
// unchanged"; non-nil sets (empty api_key clears).
|
||||||
|
type updateTagSourceReq struct {
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
APIKey *string `json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateTagSourceResp struct {
|
||||||
|
tagSourceResp
|
||||||
|
VersionBumped bool `json:"version_bumped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateTagSource implements PATCH /api/admin/tag-sources/{provider_id}.
|
||||||
|
func (h *handlers) handleUpdateTagSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "provider_id")
|
||||||
|
|
||||||
|
var body updateTagSourceReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bumped, err := h.tagSettings.UpdateProvider(r.Context(), id, tags.ProviderUpdatePatch{
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
APIKey: body.APIKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, tags.ErrProviderNotFound) {
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin: update tag source", "id", id, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("update failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var found tagSourceResp
|
||||||
|
for _, info := range h.tagSettings.ListProviderInfo() {
|
||||||
|
if info.ID == id {
|
||||||
|
found = tagSourceRespFrom(info)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, updateTagSourceResp{tagSourceResp: found, VersionBumped: bumped})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testTagSourceResp struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test.
|
||||||
|
func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "provider_id")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := h.tagSettings.TestProvider(r.Context(), id)
|
||||||
|
duration := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
writeJSON(w, http.StatusOK, testTagSourceResp{OK: true, DurationMs: duration})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, tags.ErrProviderNotFound) {
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not testable, or the test failed — both surface as ok=false with a
|
||||||
|
// 200 (the operation completed; the result is data, not an error).
|
||||||
|
writeJSON(w, http.StatusOK, testTagSourceResp{OK: false, DurationMs: duration, Error: err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type researchTagsResp struct {
|
||||||
|
SourcesVersion int32 `json:"sources_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResearchTags implements POST /api/admin/tag-sources/research: bump
|
||||||
|
// the sources version so every settled ('none') track becomes eligible for
|
||||||
|
// a re-enrichment pass, then the background worker re-processes them.
|
||||||
|
func (h *handlers) handleResearchTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
newVer, err := h.tagSettings.BumpVersion(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: research tags", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("research failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, researchTagsResp{SourcesVersion: newVer})
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apiTestTagProvider is a minimal TrackTagProvider for api-package tests.
|
||||||
|
// Does not implement TestableProvider.
|
||||||
|
type apiTestTagProvider struct {
|
||||||
|
id string
|
||||||
|
display string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiTestTagProvider) ID() string { return p.id }
|
||||||
|
func (p *apiTestTagProvider) DisplayName() string { return p.display }
|
||||||
|
func (p *apiTestTagProvider) RequiresAPIKey() bool { return false }
|
||||||
|
func (p *apiTestTagProvider) DefaultEnabled() bool { return true }
|
||||||
|
func (p *apiTestTagProvider) Configure(_ tags.ProviderSettings) error { return nil }
|
||||||
|
func (p *apiTestTagProvider) FetchTrackTags(_ context.Context, _ tags.TrackRef) ([]tags.Tag, error) {
|
||||||
|
return []tags.Tag{{Name: "x", Weight: 1}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiTestTestableTagProvider also implements TestableProvider.
|
||||||
|
type apiTestTestableTagProvider struct {
|
||||||
|
apiTestTagProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiTestTestableTagProvider) TestConnection(_ context.Context) error { return nil }
|
||||||
|
|
||||||
|
func newAdminTagSourcesRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/api/admin", func(admin chi.Router) {
|
||||||
|
admin.Use(auth.RequireAdmin())
|
||||||
|
admin.Get("/tag-sources", h.handleListTagSources)
|
||||||
|
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
||||||
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// testHandlersWithTagSettings installs a tags.SettingsService backed by the
|
||||||
|
// test DB. Register the needed fake providers BEFORE calling this, and pair
|
||||||
|
// with tags.ResetRegistryForTests in t.Cleanup.
|
||||||
|
func testHandlersWithTagSettings(t *testing.T) *handlers {
|
||||||
|
t.Helper()
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
s, err := tags.NewSettingsService(context.Background(), pool, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService: %v", err)
|
||||||
|
}
|
||||||
|
h.tagSettings = s
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminListTagSources_ReturnsRegisteredProviders(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "test-tag-prov", display: "Test Tag Provider"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tslist", "pw", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/tag-sources", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp tagSourcesListResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Providers) != 1 || resp.Providers[0].ID != "test-tag-prov" {
|
||||||
|
t.Fatalf("providers = %+v, want 1 (test-tag-prov)", resp.Providers)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, s := range resp.Providers[0].Supports {
|
||||||
|
if s == "track_tags" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("supports = %v, want to include track_tags", resp.Providers[0].Supports)
|
||||||
|
}
|
||||||
|
if resp.Providers[0].Testable {
|
||||||
|
t.Error("testable = true, want false (no TestConnection)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_FlippingEnabledBumpsVersion(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "flip-tag", display: "Flip Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tsflip", "pw", true)
|
||||||
|
|
||||||
|
disabled := false
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{Enabled: &disabled})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/flip-tag", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp updateTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.VersionBumped {
|
||||||
|
t.Error("version_bumped = false, want true after flipping enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_KeyOnlyDoesNotBump(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "key-tag", display: "Key Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tskey", "pw", true)
|
||||||
|
|
||||||
|
newKey := "newkey"
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{APIKey: &newKey})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/key-tag", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp updateTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.VersionBumped {
|
||||||
|
t.Error("version_bumped = true, want false for key-only change")
|
||||||
|
}
|
||||||
|
if !resp.APIKeySet {
|
||||||
|
t.Error("api_key_set = false, want true after setting key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_UnknownProvider404(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "ts404", "pw", true)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/missing", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestTagSource_NonAdminReturns403(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTestableTagProvider{apiTestTagProvider{id: "testable-tag", display: "Testable Tag"}})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
nonAdmin := seedUser(t, h.pool, "tsnoadmin", "pw", false)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/testable-tag/test", nil)
|
||||||
|
req = withUser(req, nonAdmin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestTagSource_NotTestableReturnsOkFalse(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "nontestable-tag", display: "Non-Testable Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tsntest", "pw", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/nontestable-tag/test", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp testTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.OK {
|
||||||
|
t.Error("ok = true, want false for non-testable provider")
|
||||||
|
}
|
||||||
|
if resp.Error == "" {
|
||||||
|
t.Error("error string empty, want a message")
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-1
@@ -22,16 +22,19 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
|
recSettings: recSettings,
|
||||||
rng: rng.Float64,
|
rng: rng.Float64,
|
||||||
lidarrCfg: lidarrCfg,
|
lidarrCfg: lidarrCfg,
|
||||||
lidarrRequests: lidarrReqs,
|
lidarrRequests: lidarrReqs,
|
||||||
@@ -40,6 +43,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
playlists: playlistsSvc,
|
playlists: playlistsSvc,
|
||||||
coverart: coverEnricher,
|
coverart: coverEnricher,
|
||||||
coverSettings: coverSettings,
|
coverSettings: coverSettings,
|
||||||
|
tagSettings: tagSettings,
|
||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
scanCfg: scanCfg,
|
scanCfg: scanCfg,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
@@ -100,6 +104,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/search", h.handleSearch)
|
authed.Get("/search", h.handleSearch)
|
||||||
authed.Get("/radio", h.handleRadio)
|
authed.Get("/radio", h.handleRadio)
|
||||||
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
||||||
|
// Snooze = "not right now", time-boxed and self-expiring
|
||||||
|
// (#2374). Not a dislike — see the migration for why.
|
||||||
|
authed.Post("/discover/suggestions/{mbid}/snooze", h.handleSnoozeSuggestion)
|
||||||
|
authed.Delete("/discover/suggestions/{mbid}/snooze", h.handleUnsnoozeSuggestion)
|
||||||
|
authed.Get("/discover/snoozes", h.handleListSuggestionSnoozes)
|
||||||
authed.Get("/home", h.handleGetHome)
|
authed.Get("/home", h.handleGetHome)
|
||||||
authed.Get("/home/index", h.handleGetHomeIndex)
|
authed.Get("/home/index", h.handleGetHomeIndex)
|
||||||
authed.Post("/events", h.handleEvents)
|
authed.Post("/events", h.handleEvents)
|
||||||
@@ -198,9 +207,22 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
||||||
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
||||||
|
|
||||||
|
admin.Get("/tag-sources", h.handleListTagSources)
|
||||||
|
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
||||||
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||||
|
admin.Post("/tag-sources/research", h.handleResearchTags)
|
||||||
|
|
||||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||||
|
|
||||||
|
// Recommendation tuning lab (#1250): scoring-weight
|
||||||
|
// profiles + taste-build knobs, DB-backed, live effect.
|
||||||
|
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||||
|
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||||
|
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||||
|
// Weekly outcome trends + knob-turn markers (#1251).
|
||||||
|
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
|
||||||
})
|
})
|
||||||
|
|
||||||
authed.Get("/playlists", h.handleListPlaylists)
|
authed.Get("/playlists", h.handleListPlaylists)
|
||||||
@@ -223,6 +245,7 @@ type handlers struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
events *playevents.Writer
|
events *playevents.Writer
|
||||||
recCfg config.RecommendationConfig
|
recCfg config.RecommendationConfig
|
||||||
|
recSettings *recsettings.Service
|
||||||
rng func() float64
|
rng func() float64
|
||||||
lidarrCfg *lidarrconfig.Service
|
lidarrCfg *lidarrconfig.Service
|
||||||
lidarrRequests *lidarrrequests.Service
|
lidarrRequests *lidarrrequests.Service
|
||||||
@@ -231,6 +254,7 @@ type handlers struct {
|
|||||||
playlists *playlists.Service
|
playlists *playlists.Service
|
||||||
coverart *coverart.Enricher
|
coverart *coverart.Enricher
|
||||||
coverSettings *coverart.SettingsService
|
coverSettings *coverart.SettingsService
|
||||||
|
tagSettings *tags.SettingsService
|
||||||
scanner *library.Scanner
|
scanner *library.Scanner
|
||||||
scanCfg library.RunScanConfig
|
scanCfg library.RunScanConfig
|
||||||
dataDir string
|
dataDir string
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,11 +57,12 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dbtest.ResetDB(t, pool)
|
dbtest.ResetDB(t, pool)
|
||||||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||||
recCfg := config.RecommendationConfig{
|
recCfg := config.RecommendationConfig{
|
||||||
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
|
||||||
SkipPenalty: 1.0, JitterMagnitude: 0.1,
|
|
||||||
ContextWeight: 2.0, SimilarityWeight: 2.0,
|
|
||||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||||
}
|
}
|
||||||
|
recSettings, err := recsettings.New(context.Background(), pool, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recsettings: %v", err)
|
||||||
|
}
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
lidarrCfg := lidarrconfig.New(pool)
|
||||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
||||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
||||||
@@ -70,7 +72,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
||||||
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
||||||
return h, pool
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -24,6 +25,33 @@ type eventRequest struct {
|
|||||||
// / "discover" feed the system-playlist rotation dedup (#415);
|
// / "discover" feed the system-playlist rotation dedup (#415);
|
||||||
// absent / "" for library, user-playlist, radio, Subsonic.
|
// absent / "" for library, user-playlist, radio, Subsonic.
|
||||||
Source *string `json:"source"`
|
Source *string `json:"source"`
|
||||||
|
// DeviceClass is the client's self-reported device class (#1551),
|
||||||
|
// normalized + stored per play to condition radio on the current device.
|
||||||
|
DeviceClass *string `json:"device_class"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// knownDeviceClasses whitelists the device classes the context-affinity facet
|
||||||
|
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
|
||||||
|
// "other", and empty/absent to "" (stored NULL → excluded from the device
|
||||||
|
// dimension). Kept permissive (no DB CHECK) so a new client class is one
|
||||||
|
// whitelist entry, not a migration.
|
||||||
|
var knownDeviceClasses = map[string]bool{
|
||||||
|
"mobile": true, "tablet": true, "desktop": true,
|
||||||
|
"web": true, "tv": true, "watch": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDeviceClass(dc *string) string {
|
||||||
|
if dc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s := strings.ToLower(strings.TrimSpace(*dc))
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if knownDeviceClasses[s] {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "other"
|
||||||
}
|
}
|
||||||
|
|
||||||
type playStartedResponse struct {
|
type playStartedResponse struct {
|
||||||
@@ -31,6 +59,26 @@ type playStartedResponse struct {
|
|||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eventFutureSkew is the tolerated client-clock drift into the future
|
||||||
|
// before a timestamp is treated as bogus and replaced with now.
|
||||||
|
const eventFutureSkew = 5 * time.Minute
|
||||||
|
|
||||||
|
// clampEventTime bounds a client-supplied event timestamp to sanity:
|
||||||
|
// no earlier than the account's creation (offline replays can
|
||||||
|
// legitimately be days old, but no play can predate the user) and no
|
||||||
|
// later than now + a small skew allowance. Unbounded client clocks
|
||||||
|
// previously let a skewed device write arbitrarily old plays, which
|
||||||
|
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
|
||||||
|
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
|
||||||
|
if at.Before(userCreatedAt) {
|
||||||
|
return userCreatedAt
|
||||||
|
}
|
||||||
|
if at.After(now.Add(eventFutureSkew)) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
type okResponse struct {
|
type okResponse struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
}
|
}
|
||||||
@@ -52,7 +100,7 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
|
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
at = parsed
|
at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC())
|
||||||
}
|
}
|
||||||
clientID := ""
|
clientID := ""
|
||||||
if req.ClientID != nil {
|
if req.ClientID != nil {
|
||||||
@@ -94,7 +142,9 @@ func (h *handlers) handleEventPlayStarted(
|
|||||||
if req.Source != nil {
|
if req.Source != nil {
|
||||||
source = *req.Source
|
source = *req.Source
|
||||||
}
|
}
|
||||||
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
|
deviceClass := normalizeDeviceClass(req.DeviceClass)
|
||||||
|
res, err := h.events.RecordPlayStartedWithSource(
|
||||||
|
r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: events: play_started", "err", err)
|
h.logger.Error("api: events: play_started", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||||
@@ -139,7 +189,8 @@ func (h *handlers) handleEventPlayOffline(
|
|||||||
source = *req.Source
|
source = *req.Source
|
||||||
}
|
}
|
||||||
if err := h.events.RecordOfflinePlay(
|
if err := h.events.RecordOfflinePlay(
|
||||||
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
|
r.Context(), user.ID, trackID, clientID, source,
|
||||||
|
normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
h.logger.Error("api: events: play_offline", "err", err)
|
h.logger.Error("api: events: play_offline", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
30*time.Minute, 0.5, 30000)
|
||||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
@@ -11,28 +13,142 @@ import (
|
|||||||
const (
|
const (
|
||||||
recMetricsDefaultDays = 30
|
recMetricsDefaultDays = 30
|
||||||
recMetricsMaxDays = 365
|
recMetricsMaxDays = 365
|
||||||
|
|
||||||
|
// recMetricsLowVolume marks a family as low-confidence rather than
|
||||||
|
// hiding it: with fewer plays than this a skip rate is anecdote, not
|
||||||
|
// signal, but silently dropping the row would misread as "surface
|
||||||
|
// unused". The web renders low-confidence rows dimmed.
|
||||||
|
recMetricsLowVolume = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// recommendationMetric is one recommendation surface's outcomes.
|
// Surface intents (milestone #127): each family is judged against its
|
||||||
type recommendationMetric struct {
|
// job, not one global bar — discovery mixes are EXPECTED to run higher
|
||||||
Source string `json:"source"` // 'for_you' | 'discover' | mixes
|
// skip rates than the go-to surfaces.
|
||||||
Plays int64 `json:"plays"` // plays launched from this surface
|
const (
|
||||||
|
intentGoTo = "go_to"
|
||||||
|
intentDiscovery = "discovery"
|
||||||
|
intentDirect = "direct"
|
||||||
|
)
|
||||||
|
|
||||||
|
// surfaceMetric is one bucketed surface family's outcomes.
|
||||||
|
type surfaceMetric struct {
|
||||||
|
Key string `json:"key"` // stable family key ("for_you", "radio", …)
|
||||||
|
Label string `json:"label"` // display label
|
||||||
|
Plays int64 `json:"plays"` // plays launched from this family
|
||||||
Skips int64 `json:"skips"` // of those, marked skipped
|
Skips int64 `json:"skips"` // of those, marked skipped
|
||||||
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||||
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||||
|
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
||||||
|
// Breakdown splits the family into the pick-kind populations its
|
||||||
|
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
|
||||||
|
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
||||||
|
// that predate attribution. Present only when the family has at
|
||||||
|
// least one attributed play; the parent row remains the sum of its
|
||||||
|
// breakdown.
|
||||||
|
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// surfaceGroup is one intent band of surface families.
|
||||||
|
type surfaceGroup struct {
|
||||||
|
Intent string `json:"intent"` // go_to | discovery | direct
|
||||||
|
Label string `json:"label"`
|
||||||
|
Surfaces []surfaceMetric `json:"surfaces"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type recommendationMetricsResp struct {
|
type recommendationMetricsResp struct {
|
||||||
WindowDays int `json:"window_days"`
|
WindowDays int `json:"window_days"`
|
||||||
Sources []recommendationMetric `json:"sources"`
|
// Baseline is the control group: plays the user picked manually
|
||||||
|
// (source IS NULL). Surfaces are judged as deltas against it; nil
|
||||||
|
// when the window holds no manual plays.
|
||||||
|
Baseline *surfaceMetric `json:"baseline"`
|
||||||
|
Groups []surfaceGroup `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// recFamily is the bucketing target for a raw play_events.source value.
|
||||||
|
type recFamily struct {
|
||||||
|
key string
|
||||||
|
label string
|
||||||
|
intent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketRecSource maps a raw client-stamped source string to its stable
|
||||||
|
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
|
||||||
|
// their family so the table stays readable at any library size.
|
||||||
|
func bucketRecSource(src string) recFamily {
|
||||||
|
switch {
|
||||||
|
case src == "for_you":
|
||||||
|
return recFamily{"for_you", "For You", intentGoTo}
|
||||||
|
case src == "songs_like_artist":
|
||||||
|
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
|
||||||
|
case src == "radio" || strings.HasPrefix(src, "radio:"):
|
||||||
|
return recFamily{"radio", "Radio", intentGoTo}
|
||||||
|
case src == "discover":
|
||||||
|
return recFamily{"discover", "Discover", intentDiscovery}
|
||||||
|
case src == "deep_cuts":
|
||||||
|
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
|
||||||
|
case src == "rediscover":
|
||||||
|
return recFamily{"rediscover", "Rediscover", intentDiscovery}
|
||||||
|
case src == "new_for_you":
|
||||||
|
return recFamily{"new_for_you", "New for you", intentDiscovery}
|
||||||
|
case src == "on_this_day":
|
||||||
|
return recFamily{"on_this_day", "On this day", intentDiscovery}
|
||||||
|
case src == "first_listens":
|
||||||
|
return recFamily{"first_listens", "First listens", intentDiscovery}
|
||||||
|
case strings.HasPrefix(src, "album:"):
|
||||||
|
return recFamily{"direct_album", "Album plays", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "artist:"):
|
||||||
|
return recFamily{"direct_artist", "Artist plays", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "offline:"):
|
||||||
|
return recFamily{"offline", "Offline pools", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "home:"):
|
||||||
|
return recFamily{"home", "Home sections", intentDirect}
|
||||||
|
case src == "history":
|
||||||
|
return recFamily{"history", "History", intentDirect}
|
||||||
|
default:
|
||||||
|
return recFamily{"other", "Other", intentDirect}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// familyAccum merges raw source rows into one family, carrying the
|
||||||
|
// completion sample count so the merged average stays play-weighted.
|
||||||
|
type familyAccum struct {
|
||||||
|
fam recFamily
|
||||||
|
plays int64
|
||||||
|
skips int64
|
||||||
|
completionN int64
|
||||||
|
// completionSum is avg*count re-expanded, so merging N raw rows
|
||||||
|
// reduces to a single weighted division at the end.
|
||||||
|
completionSum float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
||||||
|
a.plays += row.Plays
|
||||||
|
a.skips += row.Skips
|
||||||
|
a.completionN += row.CompletionN
|
||||||
|
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *familyAccum) metric() surfaceMetric {
|
||||||
|
m := surfaceMetric{
|
||||||
|
Key: a.fam.key,
|
||||||
|
Label: a.fam.label,
|
||||||
|
Plays: a.plays,
|
||||||
|
Skips: a.skips,
|
||||||
|
LowConfidence: a.plays < recMetricsLowVolume,
|
||||||
|
}
|
||||||
|
if a.plays > 0 {
|
||||||
|
m.SkipRate = float64(a.skips) / float64(a.plays)
|
||||||
|
}
|
||||||
|
if a.completionN > 0 {
|
||||||
|
m.AvgCompletion = a.completionSum / float64(a.completionN)
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||||
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
|
// Bucketed per-surface-family outcomes for the caller over the last `days`
|
||||||
// caller over the last `days` (default 30, capped at 365), so the operator can
|
// (default 30, capped at 365), grouped by surface intent and anchored by the
|
||||||
// see which recommendation surfaces are landing and tune the taste weights.
|
// manual-plays baseline so the numbers are judgeable, not just observable.
|
||||||
// Only plays tagged with a system-playlist source count; library/radio plays
|
|
||||||
// (no source) are excluded.
|
|
||||||
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
caller, ok := requireUser(w, r)
|
caller, ok := requireUser(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -51,28 +167,142 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out := recommendationMetricsResp{
|
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
||||||
WindowDays: days,
|
}
|
||||||
Sources: make([]recommendationMetric, 0, len(rows)),
|
|
||||||
|
// pickKindLabels is the display vocabulary for play_events.pick_kind
|
||||||
|
// values (mirrors the CHECK in migration 0041). Every system mix that
|
||||||
|
// stamps provenance gets its breakdown from this one map — adding a
|
||||||
|
// stamping mix needs no metrics change.
|
||||||
|
var pickKindLabels = map[string]string{
|
||||||
|
"taste": "Taste picks",
|
||||||
|
"fresh": "Fresh picks",
|
||||||
|
"dormant": "Dormant artists",
|
||||||
|
"taste_unheard": "Taste-matched",
|
||||||
|
"cross_user": "Liked by others",
|
||||||
|
"random": "Random unheard",
|
||||||
|
"tier1": "Tier 1 (exact)",
|
||||||
|
"tier2": "Tier 2 (relaxed)",
|
||||||
|
"tier3": "Tier 3 (stretched)",
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
|
||||||
|
// pick_kind — plays recorded before the mix stamped provenance, or
|
||||||
|
// whose track had rotated out of the snapshot at ingestion) renders
|
||||||
|
// last, kept visible so the parent row's sums stay transparent instead
|
||||||
|
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
|
||||||
|
// vocabulary, so iterating the list is exhaustive.
|
||||||
|
var pickKindOrder = []string{
|
||||||
|
"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
|
||||||
|
"tier1", "tier2", "tier3", "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindFamily derives the sub-family for one (family, pick_kind)
|
||||||
|
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
|
||||||
|
func pickKindFamily(parent recFamily, kind string) recFamily {
|
||||||
|
if kind == "" {
|
||||||
|
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
|
||||||
}
|
}
|
||||||
|
label, ok := pickKindLabels[kind]
|
||||||
|
if !ok {
|
||||||
|
label = kind
|
||||||
|
}
|
||||||
|
return recFamily{parent.key + "_" + kind, label, parent.intent}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindBreakdown folds a family's per-pick-kind accums into its
|
||||||
|
// Breakdown rows. Attached only when at least one attributed play
|
||||||
|
// exists — an all-unattributed breakdown would just repeat the parent
|
||||||
|
// row, and families that never stamp (radio, direct plays) stay flat.
|
||||||
|
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
||||||
|
attributed := int64(0)
|
||||||
|
for kind, acc := range picks {
|
||||||
|
if kind != "" {
|
||||||
|
attributed += acc.plays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attributed == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]surfaceMetric, 0, len(picks))
|
||||||
|
for _, kind := range pickKindOrder {
|
||||||
|
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
||||||
|
out = append(out, acc.metric())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketMetricsResponse folds the raw per-source rows into the grouped,
|
||||||
|
// baseline-anchored response shape. Split from the handler for pure-unit
|
||||||
|
// testability.
|
||||||
|
func bucketMetricsResponse(
|
||||||
|
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
|
||||||
|
) recommendationMetricsResp {
|
||||||
|
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
|
||||||
|
families := map[string]*familyAccum{}
|
||||||
|
picks := map[string]map[string]*familyAccum{}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
source := ""
|
if row.Source == nil || *row.Source == "" {
|
||||||
if row.Source != nil {
|
baseline.add(row)
|
||||||
source = *row.Source
|
continue
|
||||||
}
|
}
|
||||||
var skipRate float64
|
fam := bucketRecSource(*row.Source)
|
||||||
if row.Plays > 0 {
|
acc, exists := families[fam.key]
|
||||||
skipRate = float64(row.Skips) / float64(row.Plays)
|
if !exists {
|
||||||
|
acc = &familyAccum{fam: fam}
|
||||||
|
families[fam.key] = acc
|
||||||
}
|
}
|
||||||
out.Sources = append(out.Sources, recommendationMetric{
|
acc.add(row)
|
||||||
Source: source,
|
// Accumulate the pick-kind population unconditionally; families
|
||||||
Plays: row.Plays,
|
// that never stamp end up all-unattributed and get no breakdown.
|
||||||
Skips: row.Skips,
|
kind := ""
|
||||||
SkipRate: skipRate,
|
if row.PickKind != nil {
|
||||||
AvgCompletion: row.AvgCompletion,
|
kind = *row.PickKind
|
||||||
})
|
}
|
||||||
|
byKind, ok := picks[fam.key]
|
||||||
|
if !ok {
|
||||||
|
byKind = map[string]*familyAccum{}
|
||||||
|
picks[fam.key] = byKind
|
||||||
|
}
|
||||||
|
pick, ok := byKind[kind]
|
||||||
|
if !ok {
|
||||||
|
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
|
||||||
|
byKind[kind] = pick
|
||||||
|
}
|
||||||
|
pick.add(row)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
|
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
|
||||||
|
if baseline.plays > 0 {
|
||||||
|
m := baseline.metric()
|
||||||
|
resp.Baseline = &m
|
||||||
|
}
|
||||||
|
for _, g := range []struct{ intent, label string }{
|
||||||
|
{intentGoTo, "Go-to surfaces"},
|
||||||
|
{intentDiscovery, "Discovery mixes"},
|
||||||
|
{intentDirect, "Direct plays"},
|
||||||
|
} {
|
||||||
|
group := surfaceGroup{Intent: g.intent, Label: g.label}
|
||||||
|
for _, acc := range families {
|
||||||
|
if acc.fam.intent == g.intent {
|
||||||
|
m := acc.metric()
|
||||||
|
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
|
||||||
|
group.Surfaces = append(group.Surfaces, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(group.Surfaces) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sort.Slice(group.Surfaces, func(i, j int) bool {
|
||||||
|
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
|
||||||
|
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
|
||||||
|
}
|
||||||
|
return group.Surfaces[i].Key < group.Surfaces[j].Key
|
||||||
|
})
|
||||||
|
resp.Groups = append(resp.Groups, group)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newMetricsRouter(h *handlers) chi.Router {
|
func newMetricsRouter(h *handlers) chi.Router {
|
||||||
@@ -20,17 +22,19 @@ func newMetricsRouter(h *handlers) chi.Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
||||||
// skip flag. A nil source inserts NULL (library/radio play).
|
// skip flag. A nil source inserts NULL (manual library play → baseline).
|
||||||
|
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
|
||||||
|
// except attributed for_you plays.
|
||||||
func seedSourcedPlay(
|
func seedSourcedPlay(
|
||||||
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
||||||
source *string, completion float64, skipped bool,
|
source, pickKind *string, completion float64, skipped bool,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := h.pool.Exec(context.Background(),
|
if _, err := h.pool.Exec(context.Background(),
|
||||||
`INSERT INTO play_events
|
`INSERT INTO play_events
|
||||||
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
|
(user_id, track_id, session_id, started_at, source, pick_kind, completion_ratio, was_skipped)
|
||||||
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
|
VALUES ($1, $2, $3, now(), $4, $5, $6, $7)`,
|
||||||
userID, trackID, sessionID, source, completion, skipped); err != nil {
|
userID, trackID, sessionID, source, pickKind, completion, skipped); err != nil {
|
||||||
t.Fatalf("seed sourced play: %v", err)
|
t.Fatalf("seed sourced play: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,7 +49,168 @@ func TestRecommendationMetrics_NoSession401(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
// findSurface returns the named family from any group, or nil.
|
||||||
|
func findSurface(resp recommendationMetricsResp, key string) *surfaceMetric {
|
||||||
|
for _, g := range resp.Groups {
|
||||||
|
for i := range g.Surfaces {
|
||||||
|
if g.Surfaces[i].Key == key {
|
||||||
|
return &g.Surfaces[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("for_you"), Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 2.0 / 3},
|
||||||
|
// Two radio sessions collapse into one "radio" family; weighted
|
||||||
|
// completion = (0.2*1 + 0.8*1) / 2 = 0.5.
|
||||||
|
{Source: src("radio:aaaa"), Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2},
|
||||||
|
{Source: src("radio:bbbb"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
|
||||||
|
{Source: src("album:cccc"), Plays: 1, Skips: 0, CompletionN: 0, AvgCompletion: 0},
|
||||||
|
{Source: src("discover"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
|
||||||
|
// NULL source = manual plays → baseline, not a group row.
|
||||||
|
{Source: nil, Plays: 25, Skips: 5, CompletionN: 20, AvgCompletion: 0.9},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
if resp.Baseline == nil {
|
||||||
|
t.Fatal("baseline missing")
|
||||||
|
}
|
||||||
|
if resp.Baseline.Plays != 25 || resp.Baseline.SkipRate != 0.2 {
|
||||||
|
t.Errorf("baseline = %+v, want plays=25 skip_rate=0.2", resp.Baseline)
|
||||||
|
}
|
||||||
|
if resp.Baseline.LowConfidence {
|
||||||
|
t.Error("baseline with 25 plays should not be low-confidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
radio := findSurface(resp, "radio")
|
||||||
|
if radio == nil {
|
||||||
|
t.Fatal("radio family missing")
|
||||||
|
}
|
||||||
|
if radio.Plays != 2 || radio.Skips != 1 {
|
||||||
|
t.Errorf("radio plays/skips = %d/%d, want 2/1", radio.Plays, radio.Skips)
|
||||||
|
}
|
||||||
|
if radio.AvgCompletion < 0.49 || radio.AvgCompletion > 0.51 {
|
||||||
|
t.Errorf("radio avg_completion = %.3f, want 0.5 (play-weighted merge)", radio.AvgCompletion)
|
||||||
|
}
|
||||||
|
if !radio.LowConfidence {
|
||||||
|
t.Error("radio with 2 plays should be low-confidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := findSurface(resp, "direct_album"); s == nil || s.Plays != 1 {
|
||||||
|
t.Errorf("direct_album = %+v, want plays=1", s)
|
||||||
|
}
|
||||||
|
if s := findSurface(resp, ""); s != nil {
|
||||||
|
t.Error("NULL source must not appear as a surface family")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No attributed (taste/fresh) plays in this fixture → no breakdown;
|
||||||
|
// an all-unattributed breakdown would just repeat the parent row.
|
||||||
|
if fy := findSurface(resp, "for_you"); fy == nil || fy.Breakdown != nil {
|
||||||
|
t.Errorf("for_you breakdown = %+v, want nil without attributed plays", fy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group ordering is intent-banded: go_to before discovery before direct.
|
||||||
|
wantOrder := []string{intentGoTo, intentDiscovery, intentDirect}
|
||||||
|
if len(resp.Groups) != len(wantOrder) {
|
||||||
|
t.Fatalf("groups = %d, want %d", len(resp.Groups), len(wantOrder))
|
||||||
|
}
|
||||||
|
for i, g := range resp.Groups {
|
||||||
|
if g.Intent != wantOrder[i] {
|
||||||
|
t.Errorf("group[%d].intent = %s, want %s", i, g.Intent, wantOrder[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
kind := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("for_you"), PickKind: kind("taste"), Plays: 30, Skips: 3,
|
||||||
|
CompletionN: 30, AvgCompletion: 0.9},
|
||||||
|
{Source: src("for_you"), PickKind: kind("fresh"), Plays: 10, Skips: 4,
|
||||||
|
CompletionN: 10, AvgCompletion: 0.5},
|
||||||
|
// NULL pick_kind = plays that predate attribution.
|
||||||
|
{Source: src("for_you"), Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.7},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
fy := findSurface(resp, "for_you")
|
||||||
|
if fy == nil {
|
||||||
|
t.Fatal("for_you family missing")
|
||||||
|
}
|
||||||
|
// The parent row stays the sum of its breakdown.
|
||||||
|
if fy.Plays != 45 || fy.Skips != 8 {
|
||||||
|
t.Errorf("for_you plays/skips = %d/%d, want 45/8", fy.Plays, fy.Skips)
|
||||||
|
}
|
||||||
|
if len(fy.Breakdown) != 3 {
|
||||||
|
t.Fatalf("breakdown rows = %d, want 3 (taste, fresh, earlier)", len(fy.Breakdown))
|
||||||
|
}
|
||||||
|
wantKeys := []string{"for_you_taste", "for_you_fresh", "for_you_unattributed"}
|
||||||
|
for i, k := range wantKeys {
|
||||||
|
if fy.Breakdown[i].Key != k {
|
||||||
|
t.Errorf("breakdown[%d].key = %s, want %s", i, fy.Breakdown[i].Key, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taste, fresh := fy.Breakdown[0], fy.Breakdown[1]
|
||||||
|
if taste.Plays != 30 || taste.SkipRate != 0.1 || taste.LowConfidence {
|
||||||
|
t.Errorf("taste = %+v, want plays=30 skip_rate=0.1 confident", taste)
|
||||||
|
}
|
||||||
|
if fresh.Plays != 10 || fresh.SkipRate != 0.4 || !fresh.LowConfidence {
|
||||||
|
t.Errorf("fresh = %+v, want plays=10 skip_rate=0.4 low-confidence", fresh)
|
||||||
|
}
|
||||||
|
// Breakdown rows never appear as their own surface families.
|
||||||
|
if s := findSurface(resp, "for_you_taste"); s != nil {
|
||||||
|
t.Error("for_you_taste must not be a top-level surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
|
||||||
|
// Provenance is standard (#1270): Discover's bucket stamps surface as
|
||||||
|
// a breakdown exactly like For You's taste/fresh — this is what makes
|
||||||
|
// the bucket allocation judgeable instead of a guess.
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
kind := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("discover"), PickKind: kind("dormant"), Plays: 8, Skips: 2,
|
||||||
|
CompletionN: 8, AvgCompletion: 0.8},
|
||||||
|
{Source: src("discover"), PickKind: kind("cross_user"), Plays: 6, Skips: 3,
|
||||||
|
CompletionN: 6, AvgCompletion: 0.6},
|
||||||
|
{Source: src("discover"), PickKind: kind("random"), Plays: 4, Skips: 3,
|
||||||
|
CompletionN: 4, AvgCompletion: 0.4},
|
||||||
|
// NULL pick_kind = plays that predate bucket stamping.
|
||||||
|
{Source: src("discover"), Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
d := findSurface(resp, "discover")
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("discover family missing")
|
||||||
|
}
|
||||||
|
if d.Plays != 20 || d.Skips != 8 {
|
||||||
|
t.Errorf("discover plays/skips = %d/%d, want 20/8", d.Plays, d.Skips)
|
||||||
|
}
|
||||||
|
wantKeys := []string{
|
||||||
|
"discover_dormant", "discover_cross_user", "discover_random",
|
||||||
|
"discover_unattributed",
|
||||||
|
}
|
||||||
|
if len(d.Breakdown) != len(wantKeys) {
|
||||||
|
t.Fatalf("breakdown rows = %d, want %d", len(d.Breakdown), len(wantKeys))
|
||||||
|
}
|
||||||
|
for i, k := range wantKeys {
|
||||||
|
if d.Breakdown[i].Key != k {
|
||||||
|
t.Errorf("breakdown[%d].key = %s, want %s", i, d.Breakdown[i].Key, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b := d.Breakdown[0]; b.Label != "Dormant artists" || b.Plays != 8 {
|
||||||
|
t.Errorf("dormant row = %+v, want label=Dormant artists plays=8", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
}
|
}
|
||||||
@@ -57,15 +222,17 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
|||||||
session := seedPlaySession(t, pool, user.ID, time.Now())
|
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||||
|
|
||||||
forYou := "for_you"
|
forYou := "for_you"
|
||||||
discover := "discover"
|
radioA := "radio:11111111-1111-1111-1111-111111111111"
|
||||||
|
taste, fresh := "taste", "fresh"
|
||||||
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
|
// Two attributed as taste picks, the skipped one as a fresh pick (#1249).
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false)
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false)
|
||||||
// discover: 1 play.
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true)
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
|
// A radio session play collapses into the "radio" family.
|
||||||
// library play (NULL source) — must be excluded.
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, nil, 0.8, false)
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
|
// Manual play (NULL source) — the baseline row.
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, nil, 1.0, false)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||||
req = withUser(req, user)
|
req = withUser(req, user)
|
||||||
@@ -82,15 +249,11 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
|||||||
if resp.WindowDays != recMetricsDefaultDays {
|
if resp.WindowDays != recMetricsDefaultDays {
|
||||||
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
||||||
}
|
}
|
||||||
bySource := map[string]recommendationMetric{}
|
if resp.Baseline == nil || resp.Baseline.Plays != 1 {
|
||||||
for _, m := range resp.Sources {
|
t.Fatalf("baseline = %+v, want plays=1", resp.Baseline)
|
||||||
bySource[m.Source] = m
|
|
||||||
}
|
}
|
||||||
if _, present := bySource[""]; present {
|
fy := findSurface(resp, "for_you")
|
||||||
t.Error("NULL-source (library) plays should be excluded")
|
if fy == nil {
|
||||||
}
|
|
||||||
fy, ok := bySource["for_you"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("for_you metrics missing")
|
t.Fatal("for_you metrics missing")
|
||||||
}
|
}
|
||||||
if fy.Plays != 3 || fy.Skips != 1 {
|
if fy.Plays != 3 || fy.Skips != 1 {
|
||||||
@@ -102,7 +265,17 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
|||||||
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
||||||
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
||||||
}
|
}
|
||||||
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
|
// Pick-kind attribution surfaces as the For You breakdown (#1249).
|
||||||
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
|
if len(fy.Breakdown) != 2 {
|
||||||
|
t.Fatalf("for_you breakdown rows = %d, want 2 (taste, fresh)", len(fy.Breakdown))
|
||||||
|
}
|
||||||
|
if b := fy.Breakdown[0]; b.Key != "for_you_taste" || b.Plays != 2 || b.Skips != 0 {
|
||||||
|
t.Errorf("breakdown[0] = %+v, want for_you_taste plays=2 skips=0", b)
|
||||||
|
}
|
||||||
|
if b := fy.Breakdown[1]; b.Key != "for_you_fresh" || b.Plays != 1 || b.Skips != 1 {
|
||||||
|
t.Errorf("breakdown[1] = %+v, want for_you_fresh plays=1 skips=1", b)
|
||||||
|
}
|
||||||
|
if radio := findSurface(resp, "radio"); radio == nil || radio.Plays != 1 {
|
||||||
|
t.Errorf("radio family = %+v, want plays=1 (collapsed from radio:<uuid>)", radio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-10
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -15,6 +16,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RadioResponse is the body of GET /api/radio.
|
// RadioResponse is the body of GET /api/radio.
|
||||||
@@ -80,6 +82,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
||||||
|
// Condition on the current device (#1551): the latest play's device is a
|
||||||
|
// cheap, request-free proxy for what the user is on right now.
|
||||||
|
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
|
||||||
|
|
||||||
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
||||||
limits := recommendation.DefaultCandidateSourceLimits()
|
limits := recommendation.DefaultCandidateSourceLimits()
|
||||||
@@ -100,16 +105,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
weights := recommendation.ScoringWeights{
|
// Scoring weights come from the DB-backed tuning lab (#1250) —
|
||||||
BaseWeight: h.recCfg.BaseWeight,
|
// read per request so an admin change takes effect live.
|
||||||
LikeBoost: h.recCfg.LikeBoost,
|
weights := h.recSettings.Weights(recsettings.ScopeRadio)
|
||||||
RecencyWeight: h.recCfg.RecencyWeight,
|
|
||||||
SkipPenalty: h.recCfg.SkipPenalty,
|
|
||||||
JitterMagnitude: h.recCfg.JitterMagnitude,
|
|
||||||
ContextWeight: h.recCfg.ContextWeight,
|
|
||||||
SimilarityWeight: h.recCfg.SimilarityWeight,
|
|
||||||
TasteWeight: h.recCfg.TasteWeight,
|
|
||||||
}
|
|
||||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||||
|
|
||||||
out := make([]TrackRef, 0, len(picks)+1)
|
out := make([]TrackRef, 0, len(picks)+1)
|
||||||
@@ -156,6 +154,24 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
|
|||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// latestDeviceClass returns the device_class of the user's most recent play as
|
||||||
|
// the "current device" for context conditioning (#1551), or "" when unknown
|
||||||
|
// (no plays yet, or the latest play predates device capture). Best-effort: a
|
||||||
|
// lookup failure yields a device-agnostic ("") affinity cell.
|
||||||
|
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
|
||||||
|
dc, err := q.GetLatestPlayDeviceClassForUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
logger.Warn("api: radio: latest device class", "err", err)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if dc == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *dc
|
||||||
|
}
|
||||||
|
|
||||||
// parseExcludeParam parses a comma-separated list of UUIDs from the
|
// parseExcludeParam parses a comma-separated list of UUIDs from the
|
||||||
// `exclude` query string, silently dropping malformed entries. Returns
|
// `exclude` query string, silently dropping malformed entries. Returns
|
||||||
// nil for empty or all-malformed input.
|
// nil for empty or all-malformed input.
|
||||||
|
|||||||
+159
-1
@@ -2,13 +2,19 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
)
|
)
|
||||||
@@ -19,6 +25,12 @@ type suggestionView struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
Attribution []seedContributionView `json:"attribution"`
|
Attribution []seedContributionView `json:"attribution"`
|
||||||
|
// MatchedTags are the candidate's tags that overlap the user's taste
|
||||||
|
// profile, strongest first (#2377) — the "matches: shoegaze, melancholic"
|
||||||
|
// line. Omitted when empty, which is common: tag coverage for
|
||||||
|
// out-of-library artists is permanently partial (#2376), and the card
|
||||||
|
// falls back to the seed attribution it has always shown.
|
||||||
|
MatchedTags []string `json:"matched_tags,omitempty"`
|
||||||
// ImageURL is resolved on-demand from Lidarr (out-of-library
|
// ImageURL is resolved on-demand from Lidarr (out-of-library
|
||||||
// artists have no local art row). Omitted when Lidarr is disabled
|
// artists have no local art row). Omitted when Lidarr is disabled
|
||||||
// or has no match — the client falls back to a placeholder. Not
|
// or has no match — the client falls back to a placeholder. Not
|
||||||
@@ -65,7 +77,11 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
|
|||||||
halfLife = f
|
halfLife = f
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit)
|
// Read the tuned weight per request so an admin change takes effect on the
|
||||||
|
// next refresh, no restart (rule #25).
|
||||||
|
tagWeight := h.recSettings.Discover().TagOverlapWeight
|
||||||
|
suggestions, err := recommendation.SuggestArtists(
|
||||||
|
r.Context(), h.pool, user.ID, halfLife, limit, tagWeight)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: list suggestions", "err", err)
|
h.logger.Error("api: list suggestions", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
|
writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
|
||||||
@@ -86,12 +102,154 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
out = append(out, suggestionView{
|
out = append(out, suggestionView{
|
||||||
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
|
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
|
||||||
|
MatchedTags: s.MatchedTags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
h.resolveSuggestionArt(r.Context(), out)
|
h.resolveSuggestionArt(r.Context(), out)
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxSnoozeDays caps a client-supplied duration. The DEFAULT is not here: it's
|
||||||
|
// a DB-backed knob on the admin tuning card (rule #25), read per request via
|
||||||
|
// recSettings.Discover().SnoozeDays. See #2377.
|
||||||
|
const maxSnoozeDays = 365.0
|
||||||
|
|
||||||
|
// snoozeRequest is the POST body. Both fields are optional in the JSON sense
|
||||||
|
// (an absent body snoozes for the default), but Name is required in practice:
|
||||||
|
// candidates are out-of-library, so the server has no artists row to resolve a
|
||||||
|
// display name from and the un-snooze list would have nothing to show. The
|
||||||
|
// client always has it — it just rendered the card.
|
||||||
|
type snoozeRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Days float64 `json:"days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// snoozeView is one row of GET /api/discover/snoozes.
|
||||||
|
type snoozeView struct {
|
||||||
|
MBID string `json:"mbid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SnoozedUntil pgtype.Timestamptz `json:"snoozed_until"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSnoozeSuggestion implements
|
||||||
|
// POST /api/discover/suggestions/{mbid}/snooze.
|
||||||
|
//
|
||||||
|
// Parks a candidate for `days` (default 90, capped at 365). Idempotent:
|
||||||
|
// snoozing an already-snoozed candidate extends it rather than conflicting.
|
||||||
|
//
|
||||||
|
// This is NOT negative feedback. It records no verdict on the artist and is
|
||||||
|
// never read by internal/taste — see 0049_suggestion_snoozes.up.sql for the
|
||||||
|
// rule #101 reasoning. Returns 204.
|
||||||
|
func (h *handlers) handleSnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
|
||||||
|
if mbid == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty body is a valid "snooze this for the default period", so EOF
|
||||||
|
// is not an error here — decodeBody would reject it as a malformed body.
|
||||||
|
var body snoozeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", ""))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(body.Name)
|
||||||
|
if name == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", "name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days := body.Days
|
||||||
|
if days <= 0 {
|
||||||
|
days = h.recSettings.Discover().SnoozeDays
|
||||||
|
}
|
||||||
|
if days > maxSnoozeDays {
|
||||||
|
// Clamp rather than reject: a client asking for longer than we allow
|
||||||
|
// still means "park this", and failing the write would leave the card
|
||||||
|
// sitting there as if the tap did nothing.
|
||||||
|
days = maxSnoozeDays
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
if err := q.SnoozeSuggestion(r.Context(), dbq.SnoozeSuggestionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CandidateMbid: mbid,
|
||||||
|
CandidateName: name,
|
||||||
|
Column4: days,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("api: snooze suggestion", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("failed to snooze suggestion", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnsnoozeSuggestion implements
|
||||||
|
// DELETE /api/discover/suggestions/{mbid}/snooze.
|
||||||
|
//
|
||||||
|
// Brings a parked candidate back immediately. 404s an MBID this user never
|
||||||
|
// snoozed, so the client can tell "undone" from "there was nothing there".
|
||||||
|
func (h *handlers) handleUnsnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
|
||||||
|
if mbid == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.UnsnoozeSuggestion(r.Context(), dbq.UnsnoozeSuggestionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CandidateMbid: mbid,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: unsnooze suggestion", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("failed to unsnooze suggestion", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
writeErr(w, apierror.NotFound("snooze"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListSuggestionSnoozes implements GET /api/discover/snoozes.
|
||||||
|
//
|
||||||
|
// The un-snooze surface needs this: a parked candidate is by definition
|
||||||
|
// absent from the suggestion deck, so without a list there is no way to
|
||||||
|
// reach the DELETE above. Scoped to the caller (rule #47). Expired rows are
|
||||||
|
// already filtered by the query — the hourly gc sweep only reclaims space.
|
||||||
|
func (h *handlers) handleListSuggestionSnoozes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListActiveSuggestionSnoozes(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list suggestion snoozes", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("failed to load snoozes", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]snoozeView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, snoozeView{
|
||||||
|
MBID: row.CandidateMbid,
|
||||||
|
Name: row.CandidateName,
|
||||||
|
SnoozedUntil: row.SnoozedUntil,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
|
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
|
||||||
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
|
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
|
||||||
// Lidarr is the only source — when it's disabled, unreachable, or has
|
// Lidarr is the only source — when it's disabled, unreachable, or has
|
||||||
|
|||||||
@@ -88,20 +88,14 @@ type EventsConfig struct {
|
|||||||
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
|
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6).
|
// RecommendationConfig holds the radio path's operational knobs. The
|
||||||
// All weights are operator-tunable; defaults match the spec recommendations.
|
// scoring WEIGHTS moved to DB-backed admin settings (#1250,
|
||||||
|
// internal/recsettings) — YAML is bootstrap-only; anything an operator
|
||||||
|
// tunes lives in the UI with live effect.
|
||||||
type RecommendationConfig struct {
|
type RecommendationConfig struct {
|
||||||
BaseWeight float64 `yaml:"base_weight"`
|
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||||
LikeBoost float64 `yaml:"like_boost"`
|
RadioSize int `yaml:"radio_size"`
|
||||||
RecencyWeight float64 `yaml:"recency_weight"`
|
RadioSizeMax int `yaml:"radio_size_max"`
|
||||||
SkipPenalty float64 `yaml:"skip_penalty"`
|
|
||||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
|
||||||
ContextWeight float64 `yaml:"context_weight"`
|
|
||||||
SimilarityWeight float64 `yaml:"similarity_weight"`
|
|
||||||
TasteWeight float64 `yaml:"taste_weight"`
|
|
||||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
|
||||||
RadioSize int `yaml:"radio_size"`
|
|
||||||
RadioSizeMax int `yaml:"radio_size_max"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
@@ -120,16 +114,6 @@ func Default() Config {
|
|||||||
SkipMaxDurationPlayedMs: 30000,
|
SkipMaxDurationPlayedMs: 30000,
|
||||||
},
|
},
|
||||||
Recommendation: RecommendationConfig{
|
Recommendation: RecommendationConfig{
|
||||||
BaseWeight: 1.0,
|
|
||||||
LikeBoost: 2.0,
|
|
||||||
RecencyWeight: 1.0,
|
|
||||||
SkipPenalty: 1.0,
|
|
||||||
JitterMagnitude: 0.1,
|
|
||||||
ContextWeight: 2.0,
|
|
||||||
SimilarityWeight: 2.0,
|
|
||||||
// Radio is seed-directed (the user picked a direction), so taste
|
|
||||||
// is a lighter nudge here than in the daily mixes (1.5).
|
|
||||||
TasteWeight: 1.0,
|
|
||||||
RecentlyPlayedHours: 1,
|
RecentlyPlayedHours: 1,
|
||||||
RadioSize: 50,
|
RadioSize: 50,
|
||||||
RadioSizeMax: 200,
|
RadioSizeMax: 200,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -211,3 +211,69 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
|
||||||
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
|
FROM tracks t
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
|
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||||
|
WHERE nt.weight > 0
|
||||||
|
AND trim(g_split.g) <> ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.track_id = t.id
|
||||||
|
AND pe.was_skipped = false
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM general_likes gl
|
||||||
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
GROUP BY t.id, t.album_id, t.artist_id
|
||||||
|
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
||||||
|
LIMIT 120
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListTasteUnheardTracksForDiscoverParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Column2 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListTasteUnheardTracksForDiscoverRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
AlbumID pgtype.UUID
|
||||||
|
ArtistID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taste-targeted novelty: unheard tracks whose genres overlap the user's
|
||||||
|
// taste-profile tags (taste_profile_tags, #796), ranked by summed tag
|
||||||
|
// weight — "new to you, but your vibe" rather than the crude random arm.
|
||||||
|
// Genres live inline on tracks.genre as a delimited string, split the
|
||||||
|
// same way the radio tag_overlap arm does (regexp_split_to_table on
|
||||||
|
// [;,]). Same exclusion filters as the other buckets. Returns nothing
|
||||||
|
// when the user has no taste tags yet (cold start), so the caller
|
||||||
|
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
||||||
|
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
|
||||||
|
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTasteUnheardTracksForDiscoverRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTasteUnheardTracksForDiscoverRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
||||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events
|
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events
|
||||||
WHERE user_id = $1 AND ended_at IS NULL
|
WHERE user_id = $1 AND ended_at IS NULL
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -79,12 +79,14 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
||||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events WHERE id = $1
|
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
||||||
@@ -104,26 +106,67 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSystemPickKindForTrack = `-- name: GetSystemPickKindForTrack :one
|
||||||
|
SELECT pt.pick_kind
|
||||||
|
FROM playlist_tracks pt
|
||||||
|
JOIN playlists p ON p.id = pt.playlist_id
|
||||||
|
WHERE p.user_id = $1
|
||||||
|
AND p.system_variant = $2
|
||||||
|
AND pt.track_id = $3
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSystemPickKindForTrackParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
SystemVariant *string
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks a track up in the user's CURRENT snapshot of the given system
|
||||||
|
// variant and returns its pick_kind. Used at play-ingestion time to
|
||||||
|
// freeze provenance onto the play_event — snapshots rebuild daily, so
|
||||||
|
// attribution can't be reconstructed at read time (#1249/#1270). No
|
||||||
|
// row = track not in today's snapshot (caller stores NULL); a row with
|
||||||
|
// NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
|
||||||
|
// is non-singleton (up to 3 mixes/user); a track in two of them takes
|
||||||
|
// whichever LIMIT 1 hits — acceptable, tier stamps there describe the
|
||||||
|
// same eligibility ladder.
|
||||||
|
func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPickKindForTrackParams) (*string, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSystemPickKindForTrack, arg.UserID, arg.SystemVariant, arg.TrackID)
|
||||||
|
var pick_kind *string
|
||||||
|
err := row.Scan(&pick_kind)
|
||||||
|
return pick_kind, err
|
||||||
|
}
|
||||||
|
|
||||||
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
||||||
INSERT INTO play_events (
|
INSERT INTO play_events (
|
||||||
user_id, track_id, session_id, started_at, client_id, source
|
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
device_class
|
||||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
|
) VALUES ($1, $2, $3, $4, $5, $6, $7::text,
|
||||||
|
$8::text)
|
||||||
|
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertPlayEventParams struct {
|
type InsertPlayEventParams struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
SessionID pgtype.UUID
|
SessionID pgtype.UUID
|
||||||
StartedAt pgtype.Timestamptz
|
StartedAt pgtype.Timestamptz
|
||||||
ClientID *string
|
ClientID *string
|
||||||
Source *string
|
Source *string
|
||||||
|
PickKind *string
|
||||||
|
DeviceClass *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pick_kind is non-NULL only for system-playlist plays whose track was
|
||||||
|
// found (with a stamped kind) in the user's live snapshot for that
|
||||||
|
// variant at ingestion time (#1249, generalized in #1270).
|
||||||
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
|
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
|
||||||
row := q.db.QueryRow(ctx, insertPlayEvent,
|
row := q.db.QueryRow(ctx, insertPlayEvent,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
@@ -132,6 +175,8 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
|||||||
arg.StartedAt,
|
arg.StartedAt,
|
||||||
arg.ClientID,
|
arg.ClientID,
|
||||||
arg.Source,
|
arg.Source,
|
||||||
|
arg.PickKind,
|
||||||
|
arg.DeviceClass,
|
||||||
)
|
)
|
||||||
var i PlayEvent
|
var i PlayEvent
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -148,6 +193,8 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -214,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
|
||||||
JOIN play_events pe ON pe.track_id = t.id
|
JOIN play_events pe ON pe.track_id = t.id
|
||||||
WHERE pe.session_id = $1
|
WHERE pe.session_id = $1
|
||||||
AND pe.started_at < $2
|
AND pe.started_at < $2
|
||||||
@@ -256,6 +303,8 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -291,7 +340,7 @@ SET ended_at = $2,
|
|||||||
completion_ratio = $4,
|
completion_ratio = $4,
|
||||||
was_skipped = $5
|
was_skipped = $5
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
|
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdatePlayEventEndedParams struct {
|
type UpdatePlayEventEndedParams struct {
|
||||||
@@ -328,6 +377,8 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
const listUserHistory = `-- name: ListUserHistory :many
|
const listUserHistory = `-- name: ListUserHistory :many
|
||||||
SELECT pe.id AS event_id,
|
SELECT pe.id AS event_id,
|
||||||
pe.started_at,
|
pe.started_at,
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
@@ -77,6 +77,8 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
|
||||||
JOIN general_likes l ON l.track_id = t.id
|
JOIN general_likes l ON l.track_id = t.id
|
||||||
WHERE l.user_id = $1
|
WHERE l.user_id = $1
|
||||||
ORDER BY l.liked_at DESC
|
ORDER BY l.liked_at DESC
|
||||||
@@ -297,6 +297,8 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+117
-15
@@ -240,6 +240,19 @@ type AuditLog struct {
|
|||||||
CreatedAt pgtype.Timestamptz
|
CreatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CandidateArtistTag struct {
|
||||||
|
CandidateMbid string
|
||||||
|
Tag string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type CandidateArtistTagState struct {
|
||||||
|
CandidateMbid string
|
||||||
|
TagSource string
|
||||||
|
TagSourcesVersion int32
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type ContextualLike struct {
|
type ContextualLike struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
@@ -277,6 +290,13 @@ type DiagnosticEvent struct {
|
|||||||
ReceivedAt pgtype.Timestamptz
|
ReceivedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DiscoverTuning struct {
|
||||||
|
Singleton bool
|
||||||
|
TagOverlapWeight float64
|
||||||
|
SnoozeDays float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type GeneralLike struct {
|
type GeneralLike struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
@@ -383,6 +403,8 @@ type PlayEvent struct {
|
|||||||
SessionVectorAtPlay []byte
|
SessionVectorAtPlay []byte
|
||||||
ScrobbledAt pgtype.Timestamptz
|
ScrobbledAt pgtype.Timestamptz
|
||||||
Source *string
|
Source *string
|
||||||
|
PickKind *string
|
||||||
|
DeviceClass *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaySession struct {
|
type PlaySession struct {
|
||||||
@@ -433,6 +455,29 @@ type PlaylistTrack struct {
|
|||||||
AlbumTitle string
|
AlbumTitle string
|
||||||
DurationSec int32
|
DurationSec int32
|
||||||
AddedAt pgtype.Timestamptz
|
AddedAt pgtype.Timestamptz
|
||||||
|
PickKind *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationTuningAudit struct {
|
||||||
|
ID int64
|
||||||
|
ChangedAt pgtype.Timestamptz
|
||||||
|
Scope string
|
||||||
|
Action string
|
||||||
|
Changes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationWeightProfile struct {
|
||||||
|
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 {
|
type RegistrationSetting struct {
|
||||||
@@ -493,6 +538,14 @@ type SmtpConfig struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SuggestionSnooze struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
CandidateMbid string
|
||||||
|
CandidateName string
|
||||||
|
SnoozedUntil pgtype.Timestamptz
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type SystemPlaylistRotationState struct {
|
type SystemPlaylistRotationState struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
PlaylistKind string
|
PlaylistKind string
|
||||||
@@ -509,6 +562,21 @@ type SystemPlaylistRun struct {
|
|||||||
LastError *string
|
LastError *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TagProviderSetting struct {
|
||||||
|
ProviderID string
|
||||||
|
Enabled bool
|
||||||
|
ApiKey *string
|
||||||
|
DisplayOrder int32
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagSourcesMetum struct {
|
||||||
|
ID bool
|
||||||
|
CurrentVersion int32
|
||||||
|
LastRegisteredProvidersHash string
|
||||||
|
}
|
||||||
|
|
||||||
type TasteProfileArtist struct {
|
type TasteProfileArtist struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
@@ -516,6 +584,20 @@ type TasteProfileArtist struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TasteProfileEra struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Era string
|
||||||
|
Weight float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type TasteProfileMood struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Mood string
|
||||||
|
Weight float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type TasteProfileTag struct {
|
type TasteProfileTag struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
Tag string
|
Tag string
|
||||||
@@ -523,22 +605,36 @@ type TasteProfileTag struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TasteTuning struct {
|
||||||
|
Singleton bool
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
EnrichedTagScale float64
|
||||||
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Title string
|
Title string
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
TrackNumber *int32
|
TrackNumber *int32
|
||||||
DiscNumber *int32
|
DiscNumber *int32
|
||||||
DurationMs int32
|
DurationMs int32
|
||||||
FilePath string
|
FilePath string
|
||||||
FileSize int64
|
FileSize int64
|
||||||
FileFormat string
|
FileFormat string
|
||||||
Bitrate *int32
|
Bitrate *int32
|
||||||
Mbid *string
|
Mbid *string
|
||||||
Genre *string
|
Genre *string
|
||||||
AddedAt pgtype.Timestamptz
|
AddedAt pgtype.Timestamptz
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
TagSource *string
|
||||||
|
TagSourcesVersion int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackSimilarity struct {
|
type TrackSimilarity struct {
|
||||||
@@ -549,6 +645,12 @@ type TrackSimilarity struct {
|
|||||||
FetchedAt pgtype.Timestamptz
|
FetchedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrackTag struct {
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
Tag string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Username string
|
Username string
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
|
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
|
||||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
|
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
|
||||||
SELECT
|
SELECT
|
||||||
$1::uuid,
|
$1::uuid,
|
||||||
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
|
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
|
||||||
@@ -20,24 +20,27 @@ SELECT
|
|||||||
t.title,
|
t.title,
|
||||||
artists.name,
|
artists.name,
|
||||||
albums.title,
|
albums.title,
|
||||||
(t.duration_ms / 1000)::integer
|
(t.duration_ms / 1000)::integer,
|
||||||
|
$2::text
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE t.id = $2::uuid
|
WHERE t.id = $3::uuid
|
||||||
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at
|
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind
|
||||||
`
|
`
|
||||||
|
|
||||||
type AppendPlaylistTrackParams struct {
|
type AppendPlaylistTrackParams struct {
|
||||||
PlaylistID pgtype.UUID
|
PlaylistID pgtype.UUID
|
||||||
|
PickKind *string
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inserts at the next available position. Snapshot fields are copied
|
// Inserts at the next available position. Snapshot fields are copied
|
||||||
// from the tracks/albums/artists join at insert time. tracks.duration_ms
|
// from the tracks/albums/artists join at insert time. tracks.duration_ms
|
||||||
// is converted to seconds for the snapshot.
|
// is converted to seconds for the snapshot. pick_kind is NULL for manual
|
||||||
|
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
|
||||||
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
|
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
|
||||||
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID)
|
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
|
||||||
var i PlaylistTrack
|
var i PlaylistTrack
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.PlaylistID,
|
&i.PlaylistID,
|
||||||
@@ -48,6 +51,7 @@ func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTra
|
|||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.DurationSec,
|
&i.DurationSec,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -257,7 +261,7 @@ func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListA
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
|
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
|
||||||
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at,
|
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind,
|
||||||
t.id AS live_track_id,
|
t.id AS live_track_id,
|
||||||
albums.id AS album_id,
|
albums.id AS album_id,
|
||||||
artists.id AS artist_id
|
artists.id AS artist_id
|
||||||
@@ -278,6 +282,7 @@ type ListPlaylistTracksRow struct {
|
|||||||
AlbumTitle string
|
AlbumTitle string
|
||||||
DurationSec int32
|
DurationSec int32
|
||||||
AddedAt pgtype.Timestamptz
|
AddedAt pgtype.Timestamptz
|
||||||
|
PickKind *string
|
||||||
LiveTrackID pgtype.UUID
|
LiveTrackID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
@@ -305,6 +310,7 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID
|
|||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.DurationSec,
|
&i.DurationSec,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
|
&i.PickKind,
|
||||||
&i.LiveTrackID,
|
&i.LiveTrackID,
|
||||||
&i.AlbumID,
|
&i.AlbumID,
|
||||||
&i.ArtistID,
|
&i.ArtistID,
|
||||||
|
|||||||
@@ -11,6 +11,110 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getLatestPlayDeviceClassForUser = `-- name: GetLatestPlayDeviceClassForUser :one
|
||||||
|
SELECT device_class
|
||||||
|
FROM play_events
|
||||||
|
WHERE user_id = $1
|
||||||
|
ORDER BY started_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
// The device_class of the user's most recent play (#1551), used as the
|
||||||
|
// "current device" for radio context conditioning. NULL when the latest play
|
||||||
|
// predates device capture or came from a client that didn't send one.
|
||||||
|
func (q *Queries) GetLatestPlayDeviceClassForUser(ctx context.Context, userID pgtype.UUID) (*string, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getLatestPlayDeviceClassForUser, userID)
|
||||||
|
var device_class *string
|
||||||
|
err := row.Scan(&device_class)
|
||||||
|
return device_class, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listArtistContextPlayCountsForUser = `-- name: ListArtistContextPlayCountsForUser :many
|
||||||
|
WITH tz AS (
|
||||||
|
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
|
||||||
|
FROM users u WHERE u.id = $1
|
||||||
|
),
|
||||||
|
now_cell AS (
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
|
||||||
|
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS daypart,
|
||||||
|
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||||||
|
FROM tz
|
||||||
|
),
|
||||||
|
plays AS (
|
||||||
|
SELECT t.artist_id,
|
||||||
|
pe.device_class,
|
||||||
|
CASE
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
|
||||||
|
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS daypart,
|
||||||
|
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
CROSS JOIN tz
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.was_skipped = false
|
||||||
|
AND pe.started_at > now() - interval '365 days'
|
||||||
|
)
|
||||||
|
SELECT p.artist_id,
|
||||||
|
count(*) AS total_plays,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE p.daypart = (SELECT daypart FROM now_cell)
|
||||||
|
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
|
||||||
|
AND ($2::text = '' OR p.device_class = $2::text)
|
||||||
|
) AS cell_plays
|
||||||
|
FROM plays p
|
||||||
|
GROUP BY p.artist_id
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListArtistContextPlayCountsForUserParams struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
Column2 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListArtistContextPlayCountsForUserRow struct {
|
||||||
|
ArtistID pgtype.UUID
|
||||||
|
TotalPlays int64
|
||||||
|
CellPlays int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-artist completed-play counts split by whether each play falls in the
|
||||||
|
// CURRENT context cell, in the user's local timezone (#1531). The cell is
|
||||||
|
// daypart × weekday-type, optionally narrowed by device class (#1551): when
|
||||||
|
// $2 (the current device) is ”, the device dimension is ignored (identical to
|
||||||
|
// the time-only #1531 behaviour, used by the daily mixes which have no device);
|
||||||
|
// when $2 is set (radio), a play only counts in the cell if its device_class
|
||||||
|
// matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
|
||||||
|
// window bounds cost. Daypart buckets: night [22,5) morning [5,12)
|
||||||
|
// afternoon [12,17) evening [17,22). Weekend = ISO days 6–7 (Sat/Sun).
|
||||||
|
func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, arg ListArtistContextPlayCountsForUserParams) ([]ListArtistContextPlayCountsForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, arg.ID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListArtistContextPlayCountsForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListArtistContextPlayCountsForUserRow
|
||||||
|
if err := rows.Scan(&i.ArtistID, &i.TotalPlays, &i.CellPlays); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
||||||
WITH user_plays AS (
|
WITH user_plays AS (
|
||||||
SELECT t.artist_id, max(pe.started_at) AS last_started
|
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||||||
@@ -104,7 +208,7 @@ WITH plays AS (
|
|||||||
WHERE user_id = $2 AND was_skipped = false
|
WHERE user_id = $2 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -161,6 +265,8 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -181,7 +287,7 @@ WITH plays AS (
|
|||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -240,6 +346,8 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -577,12 +685,14 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
|
|||||||
|
|
||||||
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
pe.skip_count
|
pe.skip_count,
|
||||||
|
al.release_date AS release_date
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
|
JOIN albums al ON al.id = t.album_id
|
||||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -616,6 +726,7 @@ type LoadRadioCandidatesRow struct {
|
|||||||
LastPlayedAt pgtype.Timestamptz
|
LastPlayedAt pgtype.Timestamptz
|
||||||
PlayCount int64
|
PlayCount int64
|
||||||
SkipCount int64
|
SkipCount int64
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns all tracks except the seed and any played by the user within
|
// Returns all tracks except the seed and any played by the user within
|
||||||
@@ -650,10 +761,13 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
&i.SkipCount,
|
&i.SkipCount,
|
||||||
|
&i.ReleaseDate,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -748,6 +862,22 @@ taste_overlap AS (
|
|||||||
ORDER BY tpa.weight DESC, t.id
|
ORDER BY tpa.weight DESC, t.id
|
||||||
LIMIT $10
|
LIMIT $10
|
||||||
),
|
),
|
||||||
|
coplay_artists AS (
|
||||||
|
-- Household co-play (#1533): tracks by artists co-played across the
|
||||||
|
-- instance with the seed's artist (source='user_cooccurrence', built by
|
||||||
|
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
||||||
|
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
||||||
|
-- similar_artists since it's artist-level.
|
||||||
|
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
||||||
|
FROM artist_similarity asim
|
||||||
|
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
||||||
|
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||||||
|
WHERE asim.source = 'user_cooccurrence'
|
||||||
|
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
ORDER BY asim.score DESC, random()
|
||||||
|
LIMIT $11
|
||||||
|
),
|
||||||
random_fill AS (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -759,16 +889,18 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
UNION SELECT track_id FROM taste_overlap
|
UNION SELECT track_id FROM taste_overlap
|
||||||
|
UNION SELECT track_id FROM coplay_artists
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
LIMIT $9
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
pe.skip_count,
|
pe.skip_count,
|
||||||
|
al.release_date AS release_date,
|
||||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||||
FROM (
|
FROM (
|
||||||
SELECT track_id, sim_score FROM lb_similar
|
SELECT track_id, sim_score FROM lb_similar
|
||||||
@@ -776,9 +908,11 @@ FROM (
|
|||||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id
|
||||||
|
JOIN albums al ON al.id = t.album_id
|
||||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT max(started_at) AS last_played_at,
|
SELECT max(started_at) AS last_played_at,
|
||||||
@@ -790,7 +924,8 @@ LEFT JOIN LATERAL (
|
|||||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count
|
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
|
||||||
|
al.release_date
|
||||||
`
|
`
|
||||||
|
|
||||||
type LoadRadioCandidatesV2Params struct {
|
type LoadRadioCandidatesV2Params struct {
|
||||||
@@ -804,6 +939,7 @@ type LoadRadioCandidatesV2Params struct {
|
|||||||
Limit_4 int32
|
Limit_4 int32
|
||||||
Limit_5 int32
|
Limit_5 int32
|
||||||
Limit_6 int32
|
Limit_6 int32
|
||||||
|
Limit_7 int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadRadioCandidatesV2Row struct {
|
type LoadRadioCandidatesV2Row struct {
|
||||||
@@ -812,6 +948,7 @@ type LoadRadioCandidatesV2Row struct {
|
|||||||
LastPlayedAt pgtype.Timestamptz
|
LastPlayedAt pgtype.Timestamptz
|
||||||
PlayCount int64
|
PlayCount int64
|
||||||
SkipCount int64
|
SkipCount int64
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
SimilarityScore interface{}
|
SimilarityScore interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,7 +960,9 @@ type LoadRadioCandidatesV2Row struct {
|
|||||||
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||||
// positively-weighted taste-profile artists, so taste-relevant tracks
|
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||||||
// enter the pool even when the similarity/random arms miss them; scored
|
// enter the pool even when the similarity/random arms miss them; scored
|
||||||
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
|
||||||
|
// $11 coplay_artists K (#1533 — tracks by artists co-played across the
|
||||||
|
// instance with the seed's artist; source='user_cooccurrence').
|
||||||
//
|
//
|
||||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||||
@@ -838,6 +977,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
arg.Limit_4,
|
arg.Limit_4,
|
||||||
arg.Limit_5,
|
arg.Limit_5,
|
||||||
arg.Limit_6,
|
arg.Limit_6,
|
||||||
|
arg.Limit_7,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -862,10 +1002,13 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
&i.SkipCount,
|
&i.SkipCount,
|
||||||
|
&i.ReleaseDate,
|
||||||
&i.SimilarityScore,
|
&i.SimilarityScore,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -879,20 +1022,50 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
}
|
}
|
||||||
|
|
||||||
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
|
||||||
WITH seeds AS (
|
WITH artist_plays AS (
|
||||||
|
-- Completed plays only. The previous seed query counted every play_event,
|
||||||
|
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||||
|
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||||
|
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
profile_seeds AS (
|
||||||
|
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||||
|
),
|
||||||
|
fallback_seeds AS (
|
||||||
SELECT a.id AS artist_id,
|
SELECT a.id AS artist_id,
|
||||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||||
AS signal,
|
AS raw_signal
|
||||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
|
||||||
COUNT(pe.id)::bigint AS play_count
|
|
||||||
FROM artists a
|
FROM artists a
|
||||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
LEFT JOIN play_events pe
|
||||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||||
GROUP BY a.id, gla.artist_id
|
GROUP BY a.id, gla.artist_id
|
||||||
),
|
),
|
||||||
|
seeds AS (
|
||||||
|
SELECT s.artist_id,
|
||||||
|
ln(1.0 + s.raw_signal) AS signal,
|
||||||
|
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||||
|
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||||
|
FROM (
|
||||||
|
SELECT artist_id, raw_signal FROM profile_seeds
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||||
|
) s
|
||||||
|
LEFT JOIN general_likes_artists gla
|
||||||
|
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||||
|
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||||
|
WHERE s.raw_signal > 0
|
||||||
|
),
|
||||||
contributions AS (
|
contributions AS (
|
||||||
SELECT u.candidate_mbid,
|
SELECT u.candidate_mbid,
|
||||||
u.candidate_name,
|
u.candidate_name,
|
||||||
@@ -909,6 +1082,18 @@ contributions AS (
|
|||||||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||||||
AND r.status NOT IN ('rejected', 'failed')
|
AND r.status NOT IN ('rejected', 'failed')
|
||||||
)
|
)
|
||||||
|
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
|
||||||
|
-- per-user: the candidate returns on its own once snoozed_until
|
||||||
|
-- passes, and stays visible to everyone else meanwhile. Deliberately
|
||||||
|
-- filtered at the candidate stage, NOT folded into the score — a
|
||||||
|
-- snooze carries no opinion about the music, so it must not become a
|
||||||
|
-- ranking signal.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM suggestion_snoozes s
|
||||||
|
WHERE s.user_id = $1
|
||||||
|
AND s.candidate_mbid = u.candidate_mbid
|
||||||
|
AND s.snoozed_until > now()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
SELECT candidate_mbid,
|
SELECT candidate_mbid,
|
||||||
candidate_name,
|
candidate_name,
|
||||||
@@ -939,13 +1124,24 @@ type SuggestArtistsForUserRow struct {
|
|||||||
TopPlayCounts []int64
|
TopPlayCounts []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// M5c: per-user artist suggestions ranked by signal x similarity. The
|
// Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||||
// seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
// through artist_similarity_unmatched (out-of-library candidates only).
|
||||||
// (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
//
|
||||||
// artist_similarity_unmatched and filters out candidates already in
|
// Seeds are TIERED (rule #131) so the surface never empties:
|
||||||
// library or already in a non-terminal lidarr_request. The outer SELECT
|
//
|
||||||
// aggregates per candidate, returning the top-3 contributing seeds for
|
// tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||||
// attribution. $1=user_id, $2=half_life_days, $3=limit.
|
// SIGNED by internal/taste, so an artist the user has drifted away
|
||||||
|
// from stops contributing instead of accumulating forever.
|
||||||
|
// tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||||
|
// (new account, or before the first daily recompute).
|
||||||
|
//
|
||||||
|
// The signal is log-damped: contribution is signal x similarity, and the old
|
||||||
|
// undamped sum let one heavily-played artist's neighbours take every slot --
|
||||||
|
// entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||||
|
//
|
||||||
|
// Candidates already in the library, already requested and not terminal, or
|
||||||
|
// snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
|
||||||
|
// decay), $3=limit.
|
||||||
func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) {
|
func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
|
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -12,19 +12,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
|
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
pe.source,
|
pe.source,
|
||||||
|
pe.pick_kind,
|
||||||
count(*)::bigint AS plays,
|
count(*)::bigint AS plays,
|
||||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
COALESCE(
|
count(pe.completion_ratio)::bigint AS completion_n,
|
||||||
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
|
||||||
0)::float8 AS avg_completion
|
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.source IS NOT NULL
|
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
GROUP BY pe.source
|
GROUP BY pe.source, pe.pick_kind
|
||||||
ORDER BY plays DESC
|
ORDER BY plays DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -35,18 +33,17 @@ type RecommendationSourceMetricsForUserParams struct {
|
|||||||
|
|
||||||
type RecommendationSourceMetricsForUserRow struct {
|
type RecommendationSourceMetricsForUserRow struct {
|
||||||
Source *string
|
Source *string
|
||||||
|
PickKind *string
|
||||||
Plays int64
|
Plays int64
|
||||||
Skips int64
|
Skips int64
|
||||||
|
CompletionN int64
|
||||||
AvgCompletion float64
|
AvgCompletion float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
|
||||||
// operator can see whether each recommendation surface is landing and tune the
|
|
||||||
// taste weights. Source is stamped on play_events when a play is launched from
|
|
||||||
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
|
||||||
// NULL for library / radio / user-playlist plays, which are excluded here.
|
|
||||||
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||||
// mean completion ratio over plays that recorded one (0 when none did).
|
// mean completion ratio over the completion_n plays that recorded one.
|
||||||
|
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
||||||
|
// it is NULL for every other source, so those still group to one row.
|
||||||
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,8 +55,10 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
|
|||||||
var i RecommendationSourceMetricsForUserRow
|
var i RecommendationSourceMetricsForUserRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
&i.Plays,
|
&i.Plays,
|
||||||
&i.Skips,
|
&i.Skips,
|
||||||
|
&i.CompletionN,
|
||||||
&i.AvgCompletion,
|
&i.AvgCompletion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -71,3 +70,83 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recommendationWeeklyTrends = `-- name: RecommendationWeeklyTrends :many
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
date_trunc('week', pe.started_at)::date AS week_start,
|
||||||
|
pe.source,
|
||||||
|
count(*)::bigint AS plays,
|
||||||
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
|
count(pe.completion_ratio)::bigint AS completion_n,
|
||||||
|
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
|
||||||
|
count(*) FILTER (WHERE tpa.artist_id IS NOT NULL)::bigint AS taste_hits
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
LEFT JOIN taste_profile_artists tpa
|
||||||
|
ON tpa.user_id = pe.user_id
|
||||||
|
AND tpa.artist_id = t.artist_id
|
||||||
|
AND tpa.weight > 0
|
||||||
|
WHERE pe.started_at > now() - ($1::int * INTERVAL '1 week')
|
||||||
|
GROUP BY 1, 2
|
||||||
|
ORDER BY 1, 2
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecommendationWeeklyTrendsRow struct {
|
||||||
|
WeekStart pgtype.Date
|
||||||
|
Source *string
|
||||||
|
Plays int64
|
||||||
|
Skips int64
|
||||||
|
CompletionN int64
|
||||||
|
AvgCompletion float64
|
||||||
|
TasteHits int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||||
|
// operator can see whether each recommendation surface is landing and tune the
|
||||||
|
// taste weights. Source is stamped on play_events when a play is launched from
|
||||||
|
// a recommendation surface; NULL means the user picked the track manually —
|
||||||
|
// those rows are INCLUDED here as the baseline control group the surfaces are
|
||||||
|
// judged against (milestone #127: delta-vs-baseline is what makes the numbers
|
||||||
|
// actionable). Raw source strings are bucketed into stable surface families in
|
||||||
|
// the Go handler; completion_n is carried so family merges can weight
|
||||||
|
// avg_completion correctly.
|
||||||
|
// Weekly per-source outcome series for the tuning lab's trend view
|
||||||
|
// (#1251). Aggregated across ALL users: the tuning knobs are global,
|
||||||
|
// so judging a knob turn needs global outcomes — rows carry rates
|
||||||
|
// only, no track or user identity. NULL-source (manual) rows are
|
||||||
|
// included as the baseline family.
|
||||||
|
//
|
||||||
|
// taste_hits counts plays whose track's artist has a positive weight
|
||||||
|
// in that user's CURRENT taste profile — the "cheap recompute" option:
|
||||||
|
// retroactive over the whole window, at the cost of drift (the profile
|
||||||
|
// is today's, the play may be weeks old). Good enough to read whether
|
||||||
|
// a surface is feeding taste-fitting tracks.
|
||||||
|
// $1 window in weeks.
|
||||||
|
func (q *Queries) RecommendationWeeklyTrends(ctx context.Context, weeks int32) ([]RecommendationWeeklyTrendsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, recommendationWeeklyTrends, weeks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationWeeklyTrendsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationWeeklyTrendsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.WeekStart,
|
||||||
|
&i.Source,
|
||||||
|
&i.Plays,
|
||||||
|
&i.Skips,
|
||||||
|
&i.CompletionN,
|
||||||
|
&i.AvgCompletion,
|
||||||
|
&i.TasteHits,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,362 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: recommendation_tuning.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
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, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getTasteTuning)
|
||||||
|
var i TasteTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.HalfLifeDays,
|
||||||
|
&i.EngagementHardSkip,
|
||||||
|
&i.EngagementNeutral,
|
||||||
|
&i.EngagementFull,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.EnrichedTagScale,
|
||||||
|
&i.EraScale,
|
||||||
|
&i.MoodScale,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertTuningAudit = `-- name: InsertTuningAudit :exec
|
||||||
|
INSERT INTO recommendation_tuning_audit (scope, action, changes)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertTuningAuditParams struct {
|
||||||
|
Scope string
|
||||||
|
Action string
|
||||||
|
Changes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// changes is a jsonb array of {field, old, new} objects.
|
||||||
|
func (q *Queries) InsertTuningAudit(ctx context.Context, arg InsertTuningAuditParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertTuningAudit, arg.Scope, arg.Action, arg.Changes)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTuningAudit = `-- name: ListTuningAudit :many
|
||||||
|
SELECT id, changed_at, scope, action, changes
|
||||||
|
FROM recommendation_tuning_audit
|
||||||
|
ORDER BY changed_at DESC, id DESC
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Newest first; consumed by the metrics trend view (#1251) to annotate
|
||||||
|
// knob turns on the timeline.
|
||||||
|
func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTuningAudit, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationTuningAudit
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationTuningAudit
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ChangedAt,
|
||||||
|
&i.Scope,
|
||||||
|
&i.Action,
|
||||||
|
&i.Changes,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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, context_time_weight FROM recommendation_weight_profiles ORDER BY profile
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listWeightProfiles)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationWeightProfile
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationWeightProfile
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Profile,
|
||||||
|
&i.BaseWeight,
|
||||||
|
&i.LikeBoost,
|
||||||
|
&i.RecencyWeight,
|
||||||
|
&i.SkipPenalty,
|
||||||
|
&i.JitterMagnitude,
|
||||||
|
&i.ContextWeight,
|
||||||
|
&i.SimilarityWeight,
|
||||||
|
&i.TasteWeight,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
&i.ContextTimeWeight,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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, enriched_tag_scale, era_scale, mood_scale
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTasteTuningParams struct {
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
EnrichedTagScale float64
|
||||||
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateTasteTuning,
|
||||||
|
arg.HalfLifeDays,
|
||||||
|
arg.EngagementHardSkip,
|
||||||
|
arg.EngagementNeutral,
|
||||||
|
arg.EngagementFull,
|
||||||
|
arg.EnrichedTagScale,
|
||||||
|
arg.EraScale,
|
||||||
|
arg.MoodScale,
|
||||||
|
)
|
||||||
|
var i TasteTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.HalfLifeDays,
|
||||||
|
&i.EngagementHardSkip,
|
||||||
|
&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,
|
||||||
|
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, context_time_weight
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateWeightProfileParams struct {
|
||||||
|
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) {
|
||||||
|
row := q.db.QueryRow(ctx, updateWeightProfile,
|
||||||
|
arg.Profile,
|
||||||
|
arg.BaseWeight,
|
||||||
|
arg.LikeBoost,
|
||||||
|
arg.RecencyWeight,
|
||||||
|
arg.SkipPenalty,
|
||||||
|
arg.JitterMagnitude,
|
||||||
|
arg.ContextWeight,
|
||||||
|
arg.SimilarityWeight,
|
||||||
|
arg.TasteWeight,
|
||||||
|
arg.ContextTimeWeight,
|
||||||
|
)
|
||||||
|
var i RecommendationWeightProfile
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Profile,
|
||||||
|
&i.BaseWeight,
|
||||||
|
&i.LikeBoost,
|
||||||
|
&i.RecencyWeight,
|
||||||
|
&i.SkipPenalty,
|
||||||
|
&i.JitterMagnitude,
|
||||||
|
&i.ContextWeight,
|
||||||
|
&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, enriched_tag_scale, era_scale,
|
||||||
|
mood_scale
|
||||||
|
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (singleton) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertTasteTuningDefaultsParams struct {
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
EnrichedTagScale float64
|
||||||
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertTasteTuningDefaults,
|
||||||
|
arg.HalfLifeDays,
|
||||||
|
arg.EngagementHardSkip,
|
||||||
|
arg.EngagementNeutral,
|
||||||
|
arg.EngagementFull,
|
||||||
|
arg.EnrichedTagScale,
|
||||||
|
arg.EraScale,
|
||||||
|
arg.MoodScale,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
ContextTimeWeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendation tuning lab queries (#1250). Seeding happens via the
|
||||||
|
// recsettings boot reconcile; shipped defaults live in Go only.
|
||||||
|
// Boot reconcile: insert the shipped defaults for a profile if the row
|
||||||
|
// doesn't exist yet. Never overwrites operator-tuned values.
|
||||||
|
func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertWeightProfileDefaults,
|
||||||
|
arg.Profile,
|
||||||
|
arg.BaseWeight,
|
||||||
|
arg.LikeBoost,
|
||||||
|
arg.RecencyWeight,
|
||||||
|
arg.SkipPenalty,
|
||||||
|
arg.JitterMagnitude,
|
||||||
|
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
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user