Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
324059b2bd | ||
|
|
eec59193fa | ||
|
|
ca4832e620 | ||
|
|
cf0d37bf8e | ||
|
|
799dab029a | ||
|
|
7315e37c15 | ||
|
|
4f9b083eec | ||
|
|
f17356560d | ||
|
|
18a61f1065 | ||
|
|
6e39471a70 | ||
|
|
86af79bd2f | ||
|
|
e006de5d4b | ||
|
|
94e2cac03b | ||
|
|
b27029f674 | ||
|
|
14aa22198f | ||
|
|
1138d75a45 | ||
|
|
cf7b489fec | ||
|
|
8483948f23 | ||
|
|
0cea82984c | ||
|
|
3acac985cd | ||
|
|
d3b40342b4 | ||
|
|
4f99b42844 | ||
|
|
5044e7a055 | ||
|
|
7d45a4e5c7 | ||
|
|
fa0827f668 | ||
|
|
52d53e0044 | ||
|
|
a26ef4e93c | ||
|
|
0774f5f55f | ||
|
|
509cbe79b2 | ||
|
|
dc7b9b78fa | ||
|
|
cde74b5965 | ||
|
|
0efbf5fcaa | ||
|
|
2038028d42 | ||
|
|
723293110d | ||
|
|
41ebf1405b | ||
|
|
f2dcf2596d |
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
+40
@@ -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
|
||||||
|
|||||||
+69
-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
|
||||||
}
|
}
|
||||||
@@ -277,6 +261,24 @@ class MutationReplayer @Inject constructor(
|
|||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replays a suggestion snooze in whichever direction the payload asks for.
|
||||||
|
*
|
||||||
|
* The un-snooze branch can legitimately 404 (the row already lapsed, or a
|
||||||
|
* previous attempt landed and the response was lost). [outcomeFor] classes
|
||||||
|
* 404 as permanent → DROP, which is right: the user's intended end state
|
||||||
|
* already holds, so there is nothing left to send.
|
||||||
|
*/
|
||||||
|
private suspend fun dispatchSuggestionSnoozeToggle(payload: String): Outcome {
|
||||||
|
val decoded = json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), payload)
|
||||||
|
if (decoded.desiredSnoozed) {
|
||||||
|
discoverApi.snoozeSuggestion(decoded.mbid, SnoozeSuggestionBody(name = decoded.name))
|
||||||
|
} else {
|
||||||
|
discoverApi.unsnoozeSuggestion(decoded.mbid)
|
||||||
|
}
|
||||||
|
return Outcome.SENT
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
@@ -297,3 +299,46 @@ class MutationReplayer @Inject constructor(
|
|||||||
const val HTTP_TOO_MANY = 429
|
const val HTTP_TOO_MANY = 429
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row ids of desired-state toggles superseded by a later toggle for the same
|
||||||
|
* entity. Applies to every kind whose payload encodes a TARGET state rather
|
||||||
|
* than an action — like-toggles and suggestion snoozes (#2374) — because
|
||||||
|
* replaying a stale one last would invert the final state.
|
||||||
|
*
|
||||||
|
* Top-level and pure so it can be unit-tested without standing up a Retrofit
|
||||||
|
* instance. [rows] must be ascending by id (FIFO), which is what
|
||||||
|
* `CachedMutationDao.getAll()` returns.
|
||||||
|
*/
|
||||||
|
internal fun supersededToggleIds(rows: List<CachedMutationEntity>, json: Json): Set<Long> {
|
||||||
|
val latestByEntity = HashMap<String, Long>()
|
||||||
|
val superseded = HashSet<Long>()
|
||||||
|
rows.asSequence()
|
||||||
|
.mapNotNull { row -> toggleKeyOf(row, json)?.let { key -> key to row.id } }
|
||||||
|
.forEach { (key, id) ->
|
||||||
|
// Ascending ids mean a prior entry for this key is always older.
|
||||||
|
latestByEntity.put(key, id)?.let(superseded::add)
|
||||||
|
}
|
||||||
|
return superseded
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse key for a toggle row, or null when the row isn't a toggle — or its
|
||||||
|
* payload won't decode. Undecodable rows are deliberately left alone rather
|
||||||
|
* than grouped under a shared "corrupt" key, so one bad row can't suppress a
|
||||||
|
* good one behind it; the dispatcher DROPs it on its own.
|
||||||
|
*
|
||||||
|
* The kind is part of the key so two toggle kinds can never collide on the
|
||||||
|
* same entity id.
|
||||||
|
*/
|
||||||
|
private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (row.kind) {
|
||||||
|
MutationKind.LIKE_TOGGLE -> runCatching {
|
||||||
|
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { "${row.kind}:${it.entityType}:${it.entityId}" }
|
||||||
|
|
||||||
|
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> runCatching {
|
||||||
|
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||||
|
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
||||||
|
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|||||||
+81
@@ -7,10 +7,14 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
|||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.models.SeedContributionRef
|
import com.fabledsword.minstrel.models.SeedContributionRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
import com.fabledsword.minstrel.models.wire.ArtistSuggestionWire
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
import com.fabledsword.minstrel.models.wire.LidarrSearchResultWire
|
||||||
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
import com.fabledsword.minstrel.models.wire.SeedContributionWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
|
||||||
|
import com.fabledsword.minstrel.models.wire.SuggestionSnoozeWire
|
||||||
|
import retrofit2.HttpException
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -46,6 +50,69 @@ class DiscoverRepository @Inject constructor(
|
|||||||
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
suspend fun listSuggestions(): List<ArtistSuggestionRef> =
|
||||||
api.listSuggestions().map { it.toDomain() }
|
api.listSuggestions().map { it.toDomain() }
|
||||||
|
|
||||||
|
suspend fun listSnoozes(): List<SuggestionSnoozeRef> =
|
||||||
|
api.listSnoozes().map { it.toDomain() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parks a suggestion ("not right now"). Offline-first per rule #100: on
|
||||||
|
* transport failure the target state is queued for the replayer rather
|
||||||
|
* than dropped.
|
||||||
|
*
|
||||||
|
* Always reports success to the caller. Unlike a request, a snooze has no
|
||||||
|
* meaningful failed state to show — the user asked for a card to go away,
|
||||||
|
* and it will, either now or when the queue drains.
|
||||||
|
*/
|
||||||
|
suspend fun snoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
desiredSnoozed = true,
|
||||||
|
) { api.snoozeSuggestion(mbid, SnoozeSuggestionBody(name = name)) }
|
||||||
|
|
||||||
|
/** Brings a parked suggestion back. Same offline-first contract. */
|
||||||
|
suspend fun unsnoozeSuggestion(mbid: String, name: String): Unit = toggleSnooze(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
desiredSnoozed = false,
|
||||||
|
) { api.unsnoozeSuggestion(mbid) }
|
||||||
|
|
||||||
|
private suspend fun toggleSnooze(
|
||||||
|
mbid: String,
|
||||||
|
name: String,
|
||||||
|
desiredSnoozed: Boolean,
|
||||||
|
call: suspend () -> Unit,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
call()
|
||||||
|
} catch (e: HttpException) {
|
||||||
|
// A 4xx is the server's considered answer, not a lost call, so
|
||||||
|
// queueing it would be wrong twice over: the replay is guaranteed
|
||||||
|
// to fail again, and the enqueue would raise a "will sync when
|
||||||
|
// online" snackbar for something already settled. The common case
|
||||||
|
// is a 404 from un-snoozing a row that already lapsed — which is
|
||||||
|
// the end state the user wanted anyway.
|
||||||
|
if (!isPermanent(e.code())) {
|
||||||
|
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Transport failure — intentional swallow, same offline-first
|
||||||
|
// rationale as createRequest above. The queue carries the desired
|
||||||
|
// STATE, so a later undo supersedes this rather than fighting it
|
||||||
|
// on replay.
|
||||||
|
mutationQueue.enqueueSuggestionSnoozeToggle(mbid, name, desiredSnoozed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors MutationReplayer's classification so the enqueue decision here
|
||||||
|
* and the drop decision there can't disagree: 4xx is permanent except the
|
||||||
|
* two "retry me" statuses.
|
||||||
|
*/
|
||||||
|
private fun isPermanent(code: Int): Boolean =
|
||||||
|
code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
|
||||||
|
code != HTTP_TIMEOUT && code != HTTP_TOO_MANY
|
||||||
|
|
||||||
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
suspend fun search(query: String, kind: LidarrRequestKind): List<LidarrSearchResultRef> =
|
||||||
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
api.search(query = query, kind = kind.wire).map { it.toDomain() }
|
||||||
|
|
||||||
@@ -85,6 +152,13 @@ class DiscoverRepository @Inject constructor(
|
|||||||
RequestOutcome.QUEUED
|
RequestOutcome.QUEUED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val HTTP_CLIENT_ERR_MIN = 400
|
||||||
|
const val HTTP_CLIENT_ERR_MAX = 499
|
||||||
|
const val HTTP_TIMEOUT = 408
|
||||||
|
const val HTTP_TOO_MANY = 429
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mappers (internal — wire types stay out of UI) ──
|
// ── Mappers (internal — wire types stay out of UI) ──
|
||||||
@@ -105,6 +179,7 @@ private fun ArtistSuggestionWire.toDomain(): ArtistSuggestionRef = ArtistSuggest
|
|||||||
name = name,
|
name = name,
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
attribution = attribution.map { it.toDomain() },
|
attribution = attribution.map { it.toDomain() },
|
||||||
|
matchedTags = matchedTags,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContributionRef(
|
||||||
@@ -112,6 +187,12 @@ private fun SeedContributionWire.toDomain(): SeedContributionRef = SeedContribut
|
|||||||
isLiked = isLiked,
|
isLiked = isLiked,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun SuggestionSnoozeWire.toDomain(): SuggestionSnoozeRef = SuggestionSnoozeRef(
|
||||||
|
mbid = mbid,
|
||||||
|
name = name,
|
||||||
|
snoozedUntil = snoozedUntil,
|
||||||
|
)
|
||||||
|
|
||||||
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
private fun RequestCreatePayload.toBody(): CreateRequestBody = CreateRequestBody(
|
||||||
kind = kind,
|
kind = kind,
|
||||||
artistMbid = artistMbid,
|
artistMbid = artistMbid,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
|||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -104,6 +105,18 @@ private fun DiscoverBody(
|
|||||||
ResultsState.Idle -> SuggestionsPane(
|
ResultsState.Idle -> SuggestionsPane(
|
||||||
state = state.suggestions,
|
state = state.suggestions,
|
||||||
locallyRequestedMbids = state.locallyRequestedMbids,
|
locallyRequestedMbids = state.locallyRequestedMbids,
|
||||||
|
snoozeUi = SnoozeUi(
|
||||||
|
locallySnoozedMbids = state.locallySnoozedMbids,
|
||||||
|
snoozes = state.snoozes,
|
||||||
|
// No snackbar on snooze: the row itself flips to "Not
|
||||||
|
// right now" with an Undo, so a snackbar would only
|
||||||
|
// repeat what the user can already see — and cover the
|
||||||
|
// next row while doing it.
|
||||||
|
onSnooze = { s -> scope.launch { viewModel.snoozeSuggestion(s) } },
|
||||||
|
onUnsnooze = { mbid, name ->
|
||||||
|
scope.launch { viewModel.unsnoozeSuggestion(mbid, name) }
|
||||||
|
},
|
||||||
|
),
|
||||||
onRequest = { s ->
|
onRequest = { s ->
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val outcome = viewModel.requestSuggestion(s)
|
val outcome = viewModel.requestSuggestion(s)
|
||||||
@@ -178,10 +191,23 @@ private fun KindChips(kind: LidarrRequestKind, onChange: (LidarrRequestKind) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The snooze surface's data and callbacks, bundled rather than threaded
|
||||||
|
* through as four more parameters — the pane grew from one action to three
|
||||||
|
* with slice 4 and the signatures stopped being readable.
|
||||||
|
*/
|
||||||
|
private data class SnoozeUi(
|
||||||
|
val locallySnoozedMbids: Set<String>,
|
||||||
|
val snoozes: List<SuggestionSnoozeRef>,
|
||||||
|
val onSnooze: (ArtistSuggestionRef) -> Unit,
|
||||||
|
val onUnsnooze: (String, String) -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionsPane(
|
private fun SuggestionsPane(
|
||||||
state: SuggestionState,
|
state: SuggestionState,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
|
snoozeUi: SnoozeUi,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -194,6 +220,7 @@ private fun SuggestionsPane(
|
|||||||
)
|
)
|
||||||
is SuggestionState.Loaded -> SuggestionsList(
|
is SuggestionState.Loaded -> SuggestionsList(
|
||||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||||
|
snoozeUi = snoozeUi,
|
||||||
onRequest = onRequest,
|
onRequest = onRequest,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -202,6 +229,7 @@ private fun SuggestionsPane(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SuggestionsList(
|
private fun SuggestionsList(
|
||||||
items: List<ArtistSuggestionRef>,
|
items: List<ArtistSuggestionRef>,
|
||||||
|
snoozeUi: SnoozeUi,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
@@ -210,13 +238,61 @@ private fun SuggestionsList(
|
|||||||
) {
|
) {
|
||||||
item { SuggestionsHeader() }
|
item { SuggestionsHeader() }
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
item { CenteredMessage("Listen to or like an artist to fill this in.") }
|
// An empty deck used to mean one thing — no listening signal yet.
|
||||||
|
// With snoozing it can also mean "you parked them all", and telling
|
||||||
|
// that user to go listen to something would be wrong advice.
|
||||||
|
item {
|
||||||
|
CenteredMessage(
|
||||||
|
if (snoozeUi.snoozes.isEmpty()) {
|
||||||
|
"Listen to or like an artist to fill this in."
|
||||||
|
} else {
|
||||||
|
"Nothing new right now — the artists you've parked are below."
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
items(items = items, key = { it.mbid }) { s ->
|
items(items = items, key = { it.mbid }) { s ->
|
||||||
SuggestionTile(s = s, onRequest = { onRequest(s) })
|
SuggestionTile(
|
||||||
|
s = s,
|
||||||
|
snoozed = s.mbid in snoozeUi.locallySnoozedMbids,
|
||||||
|
onRequest = { onRequest(s) },
|
||||||
|
onSnooze = { snoozeUi.onSnooze(s) },
|
||||||
|
onUnsnooze = { snoozeUi.onUnsnooze(s.mbid, s.name) },
|
||||||
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Parked candidates live at the bottom of the same scroll, not behind a
|
||||||
|
// separate screen: it's a short list the user rarely needs, but it must
|
||||||
|
// be reachable — a snoozed candidate is gone from the deck above, so
|
||||||
|
// this is the only way back to it.
|
||||||
|
if (snoozeUi.snoozes.isNotEmpty()) {
|
||||||
|
item { SnoozedHeader() }
|
||||||
|
items(items = snoozeUi.snoozes, key = { "snoozed-${it.mbid}" }) { row ->
|
||||||
|
SnoozedTile(
|
||||||
|
row = row,
|
||||||
|
onUnsnooze = { snoozeUi.onUnsnooze(row.mbid, row.name) },
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SnoozedHeader() {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(bottom = 12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Not right now",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "These come back on their own. Nothing here counts against your taste profile.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material3.AssistChip
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -24,14 +26,22 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.composables.icons.lucide.Clock
|
||||||
import com.composables.icons.lucide.Disc3
|
import com.composables.icons.lucide.Disc3
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
import com.composables.icons.lucide.User
|
import com.composables.icons.lucide.User
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
internal fun SuggestionTile(
|
||||||
|
s: ArtistSuggestionRef,
|
||||||
|
snoozed: Boolean,
|
||||||
|
onRequest: () -> Unit,
|
||||||
|
onSnooze: () -> Unit,
|
||||||
|
onUnsnooze: () -> Unit,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -48,9 +58,13 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
|||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
if (s.attributionText.isNotEmpty()) {
|
// Once parked, the "because you liked X" line is no longer the
|
||||||
|
// useful thing to say — confirming what just happened is.
|
||||||
|
val secondary =
|
||||||
|
if (snoozed) "Not right now — hidden for a while" else s.reasonText
|
||||||
|
if (secondary.isNotEmpty()) {
|
||||||
Text(
|
Text(
|
||||||
text = s.attributionText,
|
text = secondary,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
@@ -58,7 +72,52 @@ internal fun SuggestionTile(s: ArtistSuggestionRef, onRequest: () -> Unit) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button(onClick = onRequest) { Text("Request") }
|
if (snoozed) {
|
||||||
|
TextButton(onClick = onUnsnooze) { Text("Undo") }
|
||||||
|
} else {
|
||||||
|
Button(onClick = onRequest) { Text("Request") }
|
||||||
|
IconButton(onClick = onSnooze) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Lucide.Clock,
|
||||||
|
// Rule #101: the label states what happens, and passes no
|
||||||
|
// judgement on the music. Never "not for me".
|
||||||
|
contentDescription = "Not right now — hide ${s.name} for a while",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the parked list. This exists because a snoozed candidate is by
|
||||||
|
* definition absent from the deck above, so without it there is no route back
|
||||||
|
* to an un-snooze once the card has gone.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun SnoozedTile(row: SuggestionSnoozeRef, onUnsnooze: () -> Unit) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = row.name,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Back ${row.returnsIn()}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextButton(onClick = onUnsnooze) { Text("Bring back") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import com.fabledsword.minstrel.discover.data.RequestOutcome
|
|||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
|
import com.fabledsword.minstrel.models.SuggestionSnoozeRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -27,6 +28,17 @@ data class DiscoverState(
|
|||||||
val suggestions: SuggestionState = SuggestionState.Loading,
|
val suggestions: SuggestionState = SuggestionState.Loading,
|
||||||
val results: ResultsState = ResultsState.Idle,
|
val results: ResultsState = ResultsState.Idle,
|
||||||
val locallyRequestedMbids: Set<String> = emptySet(),
|
val locallyRequestedMbids: Set<String> = emptySet(),
|
||||||
|
/**
|
||||||
|
* Parked candidates, for the manage list under the feed. Empty is the
|
||||||
|
* normal case and hides the section entirely.
|
||||||
|
*/
|
||||||
|
val snoozes: List<SuggestionSnoozeRef> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Just-snoozed MBIDs. These keep their row visible showing an Undo rather
|
||||||
|
* than yanking it out from under the user's finger; the row is gone on the
|
||||||
|
* next load, and [snoozes] is the way back after that.
|
||||||
|
*/
|
||||||
|
val locallySnoozedMbids: Set<String> = emptySet(),
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed interface SuggestionState {
|
sealed interface SuggestionState {
|
||||||
@@ -96,6 +108,47 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Refresh the parked list alongside the deck: a snooze made on another
|
||||||
|
// client should show up here, and one whose window lapsed should drop
|
||||||
|
// off. Sequenced after the deck load rather than raced with it so the
|
||||||
|
// two panes can't disagree about a candidate mid-refresh.
|
||||||
|
loadSnoozes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the parked list. Failure is deliberately silent: this is a
|
||||||
|
* secondary pane, and an error banner for it would sit above the suggestion
|
||||||
|
* feed the user actually came for. The list stays as-is and the next
|
||||||
|
* refresh retries.
|
||||||
|
*/
|
||||||
|
private suspend fun loadSnoozes() {
|
||||||
|
try {
|
||||||
|
val rows = repository.listSnoozes()
|
||||||
|
internal.update { it.copy(snoozes = rows) }
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
// Keep whatever we last showed rather than blanking the section.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parks a suggestion. Flips the row locally first so the tap registers
|
||||||
|
* immediately; the repository handles the offline case, so there is no
|
||||||
|
* failure branch to revert here — unlike the web client, where the fetch
|
||||||
|
* either lands or doesn't.
|
||||||
|
*/
|
||||||
|
suspend fun snoozeSuggestion(s: ArtistSuggestionRef) {
|
||||||
|
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids + s.mbid) }
|
||||||
|
repository.snoozeSuggestion(s.mbid, s.name)
|
||||||
|
loadSnoozes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brings a parked suggestion back, from either the card or the list. */
|
||||||
|
suspend fun unsnoozeSuggestion(mbid: String, name: String) {
|
||||||
|
internal.update { it.copy(locallySnoozedMbids = it.locallySnoozedMbids - mbid) }
|
||||||
|
repository.unsnoozeSuggestion(mbid, name)
|
||||||
|
loadSnoozes()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun runSearch() {
|
fun runSearch() {
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import com.fabledsword.minstrel.models.TrackRef
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.create
|
import retrofit2.create
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -34,9 +36,10 @@ import javax.inject.Singleton
|
|||||||
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
* reveals when the fetch lands and Room re-emits. Mirrors Flutter's
|
||||||
* per-item tile providers.
|
* per-item tile providers.
|
||||||
*
|
*
|
||||||
* `refreshIndex()` pulls `GET /api/home/index`, replaces each section
|
* `refreshIndex()` pulls `GET /api/home/index`, swaps all sections in
|
||||||
* in-place (delete-then-insert, so the section Flows re-fire), and
|
* one transaction (so the rows update together in a single emission
|
||||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
* rather than collapsing and refilling), and pre-warms the top artists
|
||||||
|
* via [HomeArtistPrewarmer].
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
// Per-section observe accessors (one per Home row) inflate the function
|
// Per-section observe accessors (one per Home row) inflate the function
|
||||||
@@ -85,72 +88,98 @@ class HomeRepository @Inject constructor(
|
|||||||
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
||||||
|
|
||||||
|
/** True when the index cache already has content on screen to protect. */
|
||||||
|
suspend fun hasCachedIndex(): Boolean = homeIndexDao.hasAny()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls /api/home/index, replaces each cached_home_index section,
|
* Pulls /api/home/index and swaps every cached_home_index section in
|
||||||
* and pre-warms the top artists. The section Flows re-fire on the
|
* a single transaction, then pre-warms the top artists. Missing
|
||||||
* index change; missing entity rows hydrate via the on-miss path.
|
* entity rows hydrate via the on-miss path.
|
||||||
|
*
|
||||||
|
* One transaction for all seven sections is deliberate: Room notifies
|
||||||
|
* observers once, on commit, so Home swaps from the old content to
|
||||||
|
* the new in a single emission. Per-section, un-transacted writes
|
||||||
|
* made every row visibly collapse to empty and refill, one after
|
||||||
|
* another (issue #2327).
|
||||||
*/
|
*/
|
||||||
suspend fun refreshIndex() {
|
suspend fun refreshIndex() {
|
||||||
val wire = api.getHomeIndex()
|
val wire = api.getHomeIndex()
|
||||||
replaceSection(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums)
|
homeIndexDao.replaceSections(
|
||||||
replaceSection(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums)
|
sections = ALL_SECTIONS,
|
||||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
rows = rowsFor(SECTION_RECENTLY_ADDED_ALBUMS, "album", wire.recentlyAddedAlbums) +
|
||||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
rowsFor(SECTION_REDISCOVER_ALBUMS, "album", wire.rediscoverAlbums) +
|
||||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
rowsFor(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists) +
|
||||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
|
rowsFor(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks) +
|
||||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
|
rowsFor(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists) +
|
||||||
|
rowsFor(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums) +
|
||||||
|
rowsFor(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists),
|
||||||
|
)
|
||||||
prewarmer.warm(
|
prewarmer.warm(
|
||||||
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
private fun rowsFor(
|
||||||
homeIndexDao.deleteBySection(section)
|
section: String,
|
||||||
if (ids.isEmpty()) return
|
entityType: String,
|
||||||
homeIndexDao.upsertAll(
|
ids: List<String>,
|
||||||
ids.mapIndexed { index, id ->
|
): List<CachedHomeIndexEntity> = ids.mapIndexed { index, id ->
|
||||||
CachedHomeIndexEntity(
|
CachedHomeIndexEntity(
|
||||||
section = section,
|
section = section,
|
||||||
position = index,
|
position = index,
|
||||||
entityType = entityType,
|
entityType = entityType,
|
||||||
entityId = id,
|
entityId = id,
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The section's ordered entity ids, deduplicated.
|
||||||
|
*
|
||||||
|
* Room re-runs the query on every write to `cached_home_index` — and
|
||||||
|
* `CachedHomeIndexEntity.fetchedAt` is stamped fresh each time — so
|
||||||
|
* comparing whole rows would call every rewrite a change. Comparing
|
||||||
|
* the id list instead means a section whose contents didn't actually
|
||||||
|
* move never restarts the `flatMapLatest` below, which would
|
||||||
|
* otherwise tear down and rebuild all of its tiles' hydration flows
|
||||||
|
* and flicker unchanged tiles (issue #2327).
|
||||||
|
*/
|
||||||
|
private fun observeSectionIds(section: String): Flow<List<String>> =
|
||||||
|
homeIndexDao.observeBySection(section)
|
||||||
|
.map { rows -> rows.map { it.entityId } }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
private fun observeAlbumSection(section: String): Flow<List<HomeTile<AlbumRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeAlbum(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeAlbum(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
private fun observeArtistSection(section: String): Flow<List<HomeTile<ArtistRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeArtist(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeArtist(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
private fun observeTrackSection(section: String): Flow<List<HomeTile<TrackRef>>> =
|
||||||
homeIndexDao.observeBySection(section).flatMapLatest { rows ->
|
observeSectionIds(section).flatMapLatest { ids ->
|
||||||
if (rows.isEmpty()) {
|
if (ids.isEmpty()) {
|
||||||
flowOf(emptyList())
|
flowOf(emptyList())
|
||||||
} else {
|
} else {
|
||||||
combine(rows.map { metadataProvider.observeTrack(it.entityId) }) { refs ->
|
combine(ids.map { metadataProvider.observeTrack(it) }) { refs ->
|
||||||
rows.mapIndexed { i, r -> HomeTile(r.entityId, refs[i]) }
|
ids.mapIndexed { i, id -> HomeTile(id, refs[i]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,5 +192,16 @@ class HomeRepository @Inject constructor(
|
|||||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
||||||
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
||||||
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
||||||
|
|
||||||
|
/** Every section [refreshIndex] owns — the unit of one atomic swap. */
|
||||||
|
val ALL_SECTIONS = listOf(
|
||||||
|
SECTION_RECENTLY_ADDED_ALBUMS,
|
||||||
|
SECTION_REDISCOVER_ALBUMS,
|
||||||
|
SECTION_REDISCOVER_ARTISTS,
|
||||||
|
SECTION_MOST_PLAYED_TRACKS,
|
||||||
|
SECTION_LAST_PLAYED_ARTISTS,
|
||||||
|
SECTION_YOU_MIGHT_LIKE_ALBUMS,
|
||||||
|
SECTION_YOU_MIGHT_LIKE_ARTISTS,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import androidx.compose.material3.SnackbarHost
|
|||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -85,30 +86,38 @@ import com.fabledsword.minstrel.playlists.widgets.OfflinePoolCard
|
|||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
|
import com.fabledsword.minstrel.shared.UpdateVeilController
|
||||||
|
import com.fabledsword.minstrel.shared.VeilOutcome
|
||||||
|
import com.fabledsword.minstrel.shared.VeilSessionResult
|
||||||
|
import com.fabledsword.minstrel.shared.VeilSettleState
|
||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ArtSettleTracker
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LocalArtSettleTracker
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
import com.fabledsword.minstrel.shared.widgets.SkeletonArtistTile
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||||
@@ -120,16 +129,22 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
|||||||
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
||||||
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
||||||
|
|
||||||
// "Updating your mixes…" veil (automatic refresh). Held through the pull
|
// "Updating your mixes…" veil. UpdateVeilController decides both whether it
|
||||||
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
|
// appears at all — only when a refresh actually changes something — and how
|
||||||
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
// long it stays, by watching the screen settle rather than by a fixed delay,
|
||||||
private const val VEIL_SETTLE_MS = 500L
|
// which lowered it while tiles and artwork were still landing (#2327).
|
||||||
|
// Near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
||||||
private const val VEIL_WIPE_MS = 280
|
private const val VEIL_WIPE_MS = 280
|
||||||
private const val VEIL_ALPHA = 0.96f
|
private const val VEIL_ALPHA = 0.96f
|
||||||
private const val VEIL_SPINNER_DP = 22
|
private const val VEIL_SPINNER_DP = 22
|
||||||
private const val VEIL_SPINNER_STROKE_DP = 2
|
private const val VEIL_SPINNER_STROKE_DP = 2
|
||||||
private const val VEIL_LABEL_GAP_DP = 12
|
private const val VEIL_LABEL_GAP_DP = 12
|
||||||
|
|
||||||
|
// Backstop on how long a manual pull keeps its own indicator while waiting
|
||||||
|
// for its successor — the veil, or the "already up to date" snackbar — so the
|
||||||
|
// two never both vanish for a frame mid-handoff.
|
||||||
|
private const val PULL_HANDOFF_TIMEOUT_MS = 2_000L
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class HomeSections(
|
data class HomeSections(
|
||||||
@@ -184,10 +199,13 @@ class HomeViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val poolMessages = Channel<String>(Channel.BUFFERED)
|
private val snackbarMessages = Channel<String>(Channel.BUFFERED)
|
||||||
|
|
||||||
/** Transient snackbar messages from offline-pool taps. */
|
/**
|
||||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
* Transient snackbar messages: offline-pool taps, playback failures, and
|
||||||
|
* the outcome of a refresh the user explicitly asked for.
|
||||||
|
*/
|
||||||
|
val transientMessages: Flow<String> = snackbarMessages.receiveAsFlow()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Copy for the most recent /home/index refresh failure; null once a
|
* Copy for the most recent /home/index refresh failure; null once a
|
||||||
@@ -197,38 +215,13 @@ class HomeViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
private val updatingInternal = MutableStateFlow(false)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True while an automatic background refresh (the 03:00 daily rebuild
|
* Cover-art loads in flight on Home, reported by every [ServerImage]
|
||||||
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
|
* under [LocalArtSettleTracker]. The veil waits on this so artwork
|
||||||
* your mixes…" veil so the section churn — delete-then-insert in
|
* arriving a beat after its tile lands behind the veil rather than
|
||||||
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
|
* popping in on screen.
|
||||||
* hidden behind the veil instead of on screen. Manual pull-to-refresh
|
|
||||||
* and cold start are NOT veiled (they own the pull spinner / skeleton).
|
|
||||||
*/
|
*/
|
||||||
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
|
val artTracker = ArtSettleTracker()
|
||||||
|
|
||||||
init {
|
|
||||||
refresh()
|
|
||||||
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
|
||||||
// load while the server was unreachable re-pulls itself the moment
|
|
||||||
// health returns — same idiom as SyncController, one layer up.
|
|
||||||
// Veiled: content is already on screen and would otherwise churn.
|
|
||||||
viewModelScope.launch {
|
|
||||||
networkStatus.recoveries().collect { refreshBehindVeil() }
|
|
||||||
}
|
|
||||||
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
|
||||||
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
|
||||||
// and You-might-like rows reflect the new snapshot without a manual
|
|
||||||
// reload. Mirrors the web SSE consumer. Veiled so the multi-section
|
|
||||||
// rebuild churn hides behind "Updating your mixes…".
|
|
||||||
viewModelScope.launch {
|
|
||||||
eventsStream.events
|
|
||||||
.filter { it.kind == "playlist.system_rebuilt" }
|
|
||||||
.collect { refreshBehindVeil() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
* Tap an offline pool: shuffle + play its cached tracks. Empty
|
||||||
@@ -241,7 +234,7 @@ class HomeViewModel @Inject constructor(
|
|||||||
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
OfflinePoolKind.LIKED -> shuffleSource.liked()
|
||||||
}.shuffled()
|
}.shuffled()
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
poolMessages.trySend("No cached ${kind.label} tracks yet")
|
snackbarMessages.trySend("No cached ${kind.label} tracks yet")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
player.setQueue(tracks, initialIndex = 0, source = "offline:${kind.name}")
|
||||||
}
|
}
|
||||||
@@ -278,14 +271,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
val detail = libraryRepository.refreshAlbumDetail(albumId)
|
||||||
if (detail.tracks.isEmpty()) {
|
if (detail.tracks.isEmpty()) {
|
||||||
poolMessages.trySend("This album has no tracks to play.")
|
snackbarMessages.trySend("This album has no tracks to play.")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
player.setQueue(detail.tracks, initialIndex = 0, source = "album:$albumId")
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
poolMessages.trySend(
|
snackbarMessages.trySend(
|
||||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -303,14 +296,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
val tracks = libraryRepository.fetchArtistTracks(artistId).shuffled()
|
||||||
if (tracks.isEmpty()) {
|
if (tracks.isEmpty()) {
|
||||||
poolMessages.trySend("This artist has no tracks to play.")
|
snackbarMessages.trySend("This artist has no tracks to play.")
|
||||||
} else {
|
} else {
|
||||||
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
player.setQueue(tracks, initialIndex = 0, source = "artist:$artistId")
|
||||||
}
|
}
|
||||||
} catch (
|
} catch (
|
||||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
) {
|
) {
|
||||||
poolMessages.trySend(
|
snackbarMessages.trySend(
|
||||||
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
"Couldn't start playback: ${ErrorCopy.fromThrowable(e)}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -328,52 +321,66 @@ class HomeViewModel @Inject constructor(
|
|||||||
suspend fun playPlaylist(playlist: PlaylistRef) {
|
suspend fun playPlaylist(playlist: PlaylistRef) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
playPlaylistShuffled(playlist, playlistsRepository, player) {
|
||||||
poolMessages.trySend(it)
|
snackbarMessages.trySend(it)
|
||||||
}
|
}
|
||||||
}.join()
|
}.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pulls both /home/index and the playlists list. Returns the Job
|
* Pulls /home/index, the playlists list and the system-playlist
|
||||||
* for the combined refresh so a pull-to-refresh wrapper can await
|
* status. Returns true when the load-bearing /home/index pull
|
||||||
* actual completion before hiding the indicator.
|
* succeeded — the veil controller retries on false and reports the
|
||||||
|
* outcome, so this must report failure rather than swallow it.
|
||||||
*/
|
*/
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
private suspend fun runRefresh(): Boolean = coroutineScope {
|
||||||
refreshError.value = null
|
// /home/index is the load-bearing pull: its failure drives the
|
||||||
val home = launch {
|
// empty-cache Error state. A failure over a populated cache
|
||||||
// /home/index is the load-bearing pull: its failure drives the
|
// stays silent — cached sections beat a full-screen error.
|
||||||
// empty-cache Error state. A failure over a populated cache
|
//
|
||||||
// stays silent — cached sections beat a full-screen error.
|
// Cleared on success, NOT at the start of each attempt: with the
|
||||||
|
// veil's retries, clearing up front made a failing cold start
|
||||||
|
// flash the "Welcome to Minstrel" empty state (empty cache + no
|
||||||
|
// error reads as Empty) between one attempt and the next.
|
||||||
|
val home = async {
|
||||||
runCatching { homeRepository.refreshIndex() }
|
runCatching { homeRepository.refreshIndex() }
|
||||||
|
.onSuccess { refreshError.value = null }
|
||||||
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
|
.isSuccess
|
||||||
}
|
}
|
||||||
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||||
val status = launch {
|
val status = launch {
|
||||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||||
.onSuccess { systemStatusInternal.value = it }
|
.onSuccess { systemStatusInternal.value = it }
|
||||||
}
|
}
|
||||||
home.join()
|
|
||||||
lists.join()
|
lists.join()
|
||||||
status.join()
|
status.join()
|
||||||
|
home.await()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Automatic background refresh with the "Updating your mixes…" veil
|
* The Error state's explicit Retry button. User-initiated, so it gets
|
||||||
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
|
* the controller's retries and reports its outcome; over an empty cache
|
||||||
* paths where Home is already on screen. Holds the veil through the
|
* there's no content to protect, so no veil goes up.
|
||||||
* pull plus a short settle so per-tile hydration lands behind it, then
|
|
||||||
* lets it wipe off. Overlapping automatic refreshes are rare enough
|
|
||||||
* (once-daily rebuild, reconnect) that a plain flag beats a counter.
|
|
||||||
*/
|
*/
|
||||||
private fun refreshBehindVeil() {
|
fun retry() = veil.request(userInitiated = true)
|
||||||
viewModelScope.launch {
|
|
||||||
updatingInternal.value = true
|
/**
|
||||||
try {
|
* Manual pull-to-refresh. Goes behind the veil like every other refresh
|
||||||
refresh().join()
|
* (operator call, 2026-07-31: the churn a pull causes is identical to
|
||||||
delay(VEIL_SETTLE_MS)
|
* the automatic paths, and a small spinner didn't hide it).
|
||||||
} finally {
|
*
|
||||||
updatingInternal.value = false
|
* Suspends until the veil has taken over OR the session has finished,
|
||||||
}
|
* so the pull indicator hands off to exactly one successor: the veil if
|
||||||
|
* content changed, the "Already up to date" snackbar if it didn't. The
|
||||||
|
* timeout is only a backstop against a session that outlives it.
|
||||||
|
*/
|
||||||
|
suspend fun refreshFromPull() {
|
||||||
|
val before = veil.finishedSessions.value
|
||||||
|
veil.request(userInitiated = true)
|
||||||
|
withTimeoutOrNull(PULL_HANDOFF_TIMEOUT_MS) {
|
||||||
|
combine(veil.visible, veil.finishedSessions) { veiled, finished ->
|
||||||
|
veiled || finished != before
|
||||||
|
}.first { it }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,6 +432,124 @@ class HomeViewModel @Inject constructor(
|
|||||||
else -> UiState.Empty
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Updating veil ───────────────────────────────────────────────
|
||||||
|
// Declared after uiState: these initialisers read it, and Kotlin runs
|
||||||
|
// property initialisers and init blocks in declaration order.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the veil watches to decide Home has stopped moving: the whole
|
||||||
|
* rendered state, plus how many covers are still loading.
|
||||||
|
*
|
||||||
|
* [UiState.Success] wraps a [HomeSections] data class, so any visible
|
||||||
|
* change — a section swapping ids, one tile hydrating from skeleton to
|
||||||
|
* album — changes this value and re-arms the veil's quiet window.
|
||||||
|
*
|
||||||
|
* Unhydrated tiles deliberately do NOT gate `quiescent`. A tile whose
|
||||||
|
* on-miss fetch soft-fails keeps a null value indefinitely
|
||||||
|
* ([MetadataProvider] swallows those errors), so treating "no
|
||||||
|
* skeletons left" as the settle condition would pin the veil to its
|
||||||
|
* hard ceiling on every refresh. They're covered by the content key
|
||||||
|
* instead: each tile that lands re-arms the window, and once they stop
|
||||||
|
* landing the screen is genuinely still.
|
||||||
|
*/
|
||||||
|
private val settleSignal: Flow<VeilSettleState> =
|
||||||
|
combine(uiState, artTracker.inFlight) { state, artInFlight ->
|
||||||
|
VeilSettleState(
|
||||||
|
contentKey = state,
|
||||||
|
hasContent = state is UiState.Success,
|
||||||
|
quiescent = artInFlight == 0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val veil = UpdateVeilController(
|
||||||
|
scope = viewModelScope,
|
||||||
|
settleSignal = settleSignal,
|
||||||
|
shouldVeil = {
|
||||||
|
// Only worth hiding churn when there's already content to
|
||||||
|
// hide. A cold load over an empty cache keeps its skeleton —
|
||||||
|
// veiling that would replace a useful affordance with an
|
||||||
|
// opaque panel. `hasCachedIndex` is the honest check: uiState
|
||||||
|
// still reads Loading until the screen subscribes, so on a
|
||||||
|
// process restore over a warm cache it would say "no content"
|
||||||
|
// right before the cache emits.
|
||||||
|
uiState.value is UiState.Success || homeRepository.hasCachedIndex()
|
||||||
|
},
|
||||||
|
onSessionEnd = ::reportRefreshOutcome,
|
||||||
|
work = ::runRefresh,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the user how a refresh *they asked for* went, in the one case
|
||||||
|
* the veil can't: when nothing changed there's no veil to see, and a
|
||||||
|
* pull that produces no visible response at all reads as broken.
|
||||||
|
*
|
||||||
|
* Only user-initiated sessions say anything. The same outcome from a
|
||||||
|
* background check — the initial load, the 03:00 rebuild, a reconnect —
|
||||||
|
* is noise, and "Already up to date" on every launch would be worse
|
||||||
|
* than silence (operator's call, 2026-07-31).
|
||||||
|
*/
|
||||||
|
private fun reportRefreshOutcome(result: VeilSessionResult) {
|
||||||
|
if (!result.userInitiated) return
|
||||||
|
when (result.outcome) {
|
||||||
|
// The veil was the feedback.
|
||||||
|
VeilOutcome.CHANGED -> return
|
||||||
|
VeilOutcome.UNCHANGED -> snackbarMessages.trySend("Already up to date")
|
||||||
|
VeilOutcome.FAILED -> snackbarMessages.trySend("Couldn't check for updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while the "Updating your mixes…" veil should be raised.
|
||||||
|
*
|
||||||
|
* Raised only when a refresh actually changes what's on screen, and then
|
||||||
|
* held until Home settles — tiles hydrated, artwork loaded — instead of
|
||||||
|
* for a fixed delay after the network pull returns (issue #2327). A
|
||||||
|
* refresh that returns what's already cached shows no veil at all;
|
||||||
|
* [reportRefreshOutcome] tells the user instead, if they asked.
|
||||||
|
*/
|
||||||
|
val isUpdating: StateFlow<Boolean> = veil.visible
|
||||||
|
|
||||||
|
init {
|
||||||
|
// Every refresh path goes through the controller, which decides
|
||||||
|
// per session whether to raise the veil. That includes the initial
|
||||||
|
// load: over a warm cache it's a full re-pull that churns every
|
||||||
|
// section, and it used to run completely unveiled.
|
||||||
|
veil.request()
|
||||||
|
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
||||||
|
// load while the server was unreachable re-pulls itself the moment
|
||||||
|
// health returns — same idiom as SyncController, one layer up.
|
||||||
|
// This is also the recovery that keeps trying after the veil has
|
||||||
|
// given up and lowered; the controller sets no latch against it.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { veil.request() }
|
||||||
|
}
|
||||||
|
// Server-side changes that rewrite what Home renders (#968 and
|
||||||
|
// the 2026-07-31 widening) re-pull behind the veil. Mirrors the
|
||||||
|
// web SSE consumer.
|
||||||
|
viewModelScope.launch {
|
||||||
|
eventsStream.events
|
||||||
|
.filter { it.kind in VEILED_EVENT_KINDS }
|
||||||
|
.collect { veil.request() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/**
|
||||||
|
* Events that change what Home shows. `playlist.system_rebuilt`
|
||||||
|
* is the 03:00 daily rebuild; the other `playlist.*` kinds move
|
||||||
|
* the Playlists and Songs-like rows; `scan.run_finished` changes
|
||||||
|
* Recently added (and Home never reacted to it at all before).
|
||||||
|
*/
|
||||||
|
private val VEILED_EVENT_KINDS = setOf(
|
||||||
|
"playlist.system_rebuilt",
|
||||||
|
"playlist.created",
|
||||||
|
"playlist.updated",
|
||||||
|
"playlist.deleted",
|
||||||
|
"playlist.tracks_changed",
|
||||||
|
"scan.run_finished",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Screen ──────────────────────────────────────────────────────────
|
// ─── Screen ──────────────────────────────────────────────────────────
|
||||||
@@ -454,14 +579,20 @@ fun HomeScreen(
|
|||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refreshFromPull() },
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
// Every cover below reports its load state to the tracker,
|
||||||
// Automatic-refresh veil: the daily rebuild / reconnect
|
// so the veil can wait for artwork instead of guessing.
|
||||||
// churn hides behind an "Updating your mixes…" wipe. Manual
|
CompositionLocalProvider(
|
||||||
// pull owns the PullToRefreshBox spinner instead.
|
LocalArtSettleTracker provides viewModel.artTracker,
|
||||||
|
) {
|
||||||
|
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||||
|
}
|
||||||
|
// Refresh veil: rebuild / reconnect / pull / event churn all
|
||||||
|
// hide behind an "Updating your mixes…" wipe that stays up
|
||||||
|
// until the screen has actually stopped moving.
|
||||||
UpdatingVeil(visible = updating)
|
UpdatingVeil(visible = updating)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -499,7 +630,7 @@ private fun HomeStateCrossfade(
|
|||||||
is UiState.Error -> ErrorRetry(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load home",
|
title = "Couldn't load home",
|
||||||
message = s.message,
|
message = s.message,
|
||||||
onRetry = { viewModel.refresh() },
|
onRetry = { viewModel.retry() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HomeSuccessContent(
|
is UiState.Success -> HomeSuccessContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package com.fabledsword.minstrel.models
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Kind of Lidarr request being created. Wire form is the lowercase
|
* Kind of Lidarr request being created. Wire form is the lowercase
|
||||||
* enum name; the helper [wire] keeps that mapping in one place.
|
* enum name; the helper [wire] keeps that mapping in one place.
|
||||||
@@ -49,6 +52,8 @@ data class ArtistSuggestionRef(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val imageUrl: String = "",
|
val imageUrl: String = "",
|
||||||
val attribution: List<SeedContributionRef> = emptyList(),
|
val attribution: List<SeedContributionRef> = emptyList(),
|
||||||
|
/** Taste-profile tags this candidate matches, strongest first (#2377). */
|
||||||
|
val matchedTags: List<String> = emptyList(),
|
||||||
) {
|
) {
|
||||||
val attributionText: String
|
val attributionText: String
|
||||||
get() {
|
get() {
|
||||||
@@ -63,7 +68,92 @@ data class ArtistSuggestionRef(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subtitle line for the card.
|
||||||
|
*
|
||||||
|
* Prefers the taste-tag reason over seed attribution when we have one,
|
||||||
|
* because it describes the MUSIC ("sounds like what you like") rather than
|
||||||
|
* the graph ("adjacent to something you played") — the whole point of
|
||||||
|
* milestone #268 slice 6. Falls back to attribution, which is the common
|
||||||
|
* case: tag coverage for out-of-library artists is partial by nature
|
||||||
|
* (#2376), so most candidates have no matched tags.
|
||||||
|
*
|
||||||
|
* Kept in lockstep with the web client's reasonText() in
|
||||||
|
* SuggestionFeed.svelte — same wording, same Oxford comma.
|
||||||
|
*/
|
||||||
|
val reasonText: String
|
||||||
|
get() {
|
||||||
|
val tags = matchedTags.take(MAX_ATTRIBUTION_PHRASES)
|
||||||
|
return when (tags.size) {
|
||||||
|
0 -> attributionText
|
||||||
|
1 -> "Matches your taste in ${tags[0]}."
|
||||||
|
2 -> "Matches your taste in ${tags[0]} and ${tags[1]}."
|
||||||
|
else -> "Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val MAX_ATTRIBUTION_PHRASES = 3
|
private const val MAX_ATTRIBUTION_PHRASES = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A suggestion the user parked with "not right now" (#2374).
|
||||||
|
*
|
||||||
|
* Deliberately NOT a dislike: it carries no verdict on the artist, expires on
|
||||||
|
* its own, and never reaches the taste profile. Anything that treats this as
|
||||||
|
* negative preference signal is a bug.
|
||||||
|
*
|
||||||
|
* [snoozedUntil] is the raw RFC3339 string from the wire. Only the server
|
||||||
|
* decides whether a snooze is still in effect — every row the client receives
|
||||||
|
* already is — so this is read purely to phrase "back in about 3 months".
|
||||||
|
*/
|
||||||
|
data class SuggestionSnoozeRef(
|
||||||
|
val mbid: String,
|
||||||
|
val name: String,
|
||||||
|
val snoozedUntil: String,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* Relative return phrase for the manage list. Relative rather than a
|
||||||
|
* calendar date because the exact day a 90-day snooze lapses is noise the
|
||||||
|
* user never asked for.
|
||||||
|
*
|
||||||
|
* [nowMs] is injectable so this is testable without freezing the clock.
|
||||||
|
* Returns "shortly" for an unparseable or already-past timestamp: the row
|
||||||
|
* is on screen, so the server still considers it snoozed, and guessing is
|
||||||
|
* better than rendering an empty line.
|
||||||
|
*/
|
||||||
|
fun returnsIn(nowMs: Long = System.currentTimeMillis()): String {
|
||||||
|
val remainingMs = runCatching { Instant.parse(snoozedUntil).toEpochMilliseconds() }
|
||||||
|
.getOrNull()?.minus(nowMs)
|
||||||
|
// Two ways to have nothing to state: an unparseable timestamp, or one
|
||||||
|
// already lapsed by our clock though the server still returned the row
|
||||||
|
// (the two disagree). Neither is "today", which would read as a real
|
||||||
|
// prediction.
|
||||||
|
if (remainingMs == null || remainingMs <= 0) return "shortly"
|
||||||
|
val days = (remainingMs.toDouble() / MILLIS_PER_DAY).roundToInt()
|
||||||
|
return when {
|
||||||
|
days < 1 -> "today"
|
||||||
|
days == 1 -> "tomorrow"
|
||||||
|
days < DAYS_BEFORE_MONTHS -> "in $days days"
|
||||||
|
else -> {
|
||||||
|
val months = (days.toDouble() / DAYS_PER_MONTH).roundToInt()
|
||||||
|
if (months == 1) "in about a month" else "in about $months months"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val MILLIS_PER_DAY = 86_400_000.0
|
||||||
|
|
||||||
|
// Below this, days read more naturally than a rounded month count.
|
||||||
|
//
|
||||||
|
// Must be <= DAYS_PER_MONTH, or the singular "in about a month" is
|
||||||
|
// unreachable: a rounded month count of 1 needs 15..44 days, and any
|
||||||
|
// threshold above 30 sends all of those down the days branch instead.
|
||||||
|
// This was 45 and the singular branch was dead code — the unit test
|
||||||
|
// for it is what surfaced that.
|
||||||
|
const val DAYS_BEFORE_MONTHS = 30
|
||||||
|
const val DAYS_PER_MONTH = 30.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ data class ArtistSuggestionWire(
|
|||||||
val name: String = "",
|
val name: String = "",
|
||||||
@SerialName("image_url") val imageUrl: String = "",
|
@SerialName("image_url") val imageUrl: String = "",
|
||||||
val attribution: List<SeedContributionWire> = emptyList(),
|
val attribution: List<SeedContributionWire> = emptyList(),
|
||||||
|
/**
|
||||||
|
* Tags this candidate shares with the user's taste profile, strongest
|
||||||
|
* first (max 3, #2377). Absent for most candidates — tag coverage for
|
||||||
|
* out-of-library artists is partial by nature (#2376) — so the default
|
||||||
|
* empty list is the common case, not an error.
|
||||||
|
*/
|
||||||
|
@SerialName("matched_tags") val matchedTags: List<String> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,6 +55,36 @@ data class SeedContributionWire(
|
|||||||
@SerialName("is_liked") val isLiked: Boolean = false,
|
@SerialName("is_liked") val isLiked: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of `GET /api/discover/snoozes` — a suggestion the user parked
|
||||||
|
* with "not right now". The server only returns rows that are still in
|
||||||
|
* effect, so the client never compares [snoozedUntil] against the clock to
|
||||||
|
* decide whether to show it; it reads it only to say when the artist comes
|
||||||
|
* back.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SuggestionSnoozeWire(
|
||||||
|
val mbid: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
@SerialName("snoozed_until") val snoozedUntil: String = "",
|
||||||
|
@SerialName("created_at") val createdAt: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Body for `POST /api/discover/suggestions/{mbid}/snooze`.
|
||||||
|
*
|
||||||
|
* [name] is required by the server, not decorative: suggestions are
|
||||||
|
* out-of-library, so there is no artists row to resolve a display name from
|
||||||
|
* and the snooze list would have nothing to render. Omitting it is a 400.
|
||||||
|
*
|
||||||
|
* No `days` field. The duration is the server's to own (90 days); pinning it
|
||||||
|
* client-side would freeze the default at whatever this build shipped.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class SnoozeSuggestionBody(
|
||||||
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
* Body posted to `POST /api/requests`. Mirrors the Flutter `createRequest`
|
||||||
* payload shape. Optional fields are emitted only when non-null
|
* payload shape. Optional fields are emitted only when non-null
|
||||||
|
|||||||
@@ -288,6 +288,37 @@ class PlayerController @Inject constructor(
|
|||||||
controller.addMediaItem(track.toMediaItem(source = null))
|
controller.addMediaItem(track.toMediaItem(source = null))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder the queue: move the item at [from] to [to], keeping the domain
|
||||||
|
* snapshot in lock-step with the player's MediaItem timeline. Media3 emits
|
||||||
|
* onEvents → uiState reflects the new order (and the still-playing item's
|
||||||
|
* index). No-op on bad indices or a no-move.
|
||||||
|
*/
|
||||||
|
fun moveInQueue(from: Int, to: Int) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (from !in queueRefs.indices || to !in queueRefs.indices || from == to) return
|
||||||
|
queueRefs = queueRefs.toMutableList().apply { add(to, removeAt(from)) }
|
||||||
|
controller.moveMediaItem(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the queue item at [index]. When it's the currently-playing item
|
||||||
|
* Media3 advances to the next automatically. No-op on a bad index.
|
||||||
|
*/
|
||||||
|
fun removeFromQueue(index: Int) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (index !in queueRefs.indices) return
|
||||||
|
queueRefs = queueRefs.toMutableList().apply { removeAt(index) }
|
||||||
|
controller.removeMediaItem(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empty the queue and stop playback. */
|
||||||
|
fun clearQueue() {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
queueRefs = emptyList()
|
||||||
|
controller.clearMediaItems()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
* Seed a fresh radio queue from [trackId]. The `source` tag is
|
||||||
* "radio:<id>" so the server-side rotation reporter can
|
* "radio:<id>" so the server-side rotation reporter can
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import javax.inject.Inject
|
|||||||
* stub-test for ViewModel-level logic when it grows).
|
* stub-test for ViewModel-level logic when it grows).
|
||||||
*/
|
*/
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
@Suppress("TooManyFunctions") // Thin transport facade — each fun forwards to PlayerController.
|
||||||
class PlayerViewModel @Inject constructor(
|
class PlayerViewModel @Inject constructor(
|
||||||
private val controller: PlayerController,
|
private val controller: PlayerController,
|
||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
@@ -55,6 +56,9 @@ class PlayerViewModel @Inject constructor(
|
|||||||
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
fun seekToIndex(index: Int) = controller.seekToIndex(index)
|
||||||
fun toggleShuffle() = controller.toggleShuffle()
|
fun toggleShuffle() = controller.toggleShuffle()
|
||||||
fun cycleRepeat() = controller.cycleRepeat()
|
fun cycleRepeat() = controller.cycleRepeat()
|
||||||
|
fun moveInQueue(from: Int, to: Int) = controller.moveInQueue(from, to)
|
||||||
|
fun removeFromQueue(index: Int) = controller.removeFromQueue(index)
|
||||||
|
fun clearQueue() = controller.clearQueue()
|
||||||
|
|
||||||
fun toggleLikeTrack(trackId: String) {
|
fun toggleLikeTrack(trackId: String) {
|
||||||
val desired = trackId !in likedTrackIds.value
|
val desired = trackId !in likedTrackIds.value
|
||||||
|
|||||||
@@ -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,23 +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.LikeButton
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -50,12 +78,30 @@ fun QueueScreen(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("Queue") },
|
title = {
|
||||||
|
Column {
|
||||||
|
Text("Queue")
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = queueSummary(state.queue),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = { navController.popBackStack() }) {
|
IconButton(onClick = { navController.popBackStack() }) {
|
||||||
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
Icon(Lucide.ArrowLeft, contentDescription = "Back")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions = {
|
||||||
|
if (state.queue.isNotEmpty()) {
|
||||||
|
IconButton(onClick = viewModel::clearQueue) {
|
||||||
|
Icon(Lucide.Trash2, contentDescription = "Clear queue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { inner ->
|
) { inner ->
|
||||||
@@ -72,12 +118,15 @@ fun QueueScreen(
|
|||||||
likedTrackIds = likedTrackIds,
|
likedTrackIds = likedTrackIds,
|
||||||
onJumpTo = viewModel::seekToIndex,
|
onJumpTo = viewModel::seekToIndex,
|
||||||
onToggleLike = viewModel::toggleLikeTrack,
|
onToggleLike = viewModel::toggleLikeTrack,
|
||||||
|
onMove = viewModel::moveInQueue,
|
||||||
|
onRemove = viewModel::removeFromQueue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Suppress("LongParameterList") // Compose list wiring — layout + queue callbacks, not logic.
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueList(
|
private fun QueueList(
|
||||||
tracks: List<TrackRef>,
|
tracks: List<TrackRef>,
|
||||||
@@ -85,29 +134,90 @@ private fun QueueList(
|
|||||||
likedTrackIds: Set<String>,
|
likedTrackIds: Set<String>,
|
||||||
onJumpTo: (Int) -> Unit,
|
onJumpTo: (Int) -> Unit,
|
||||||
onToggleLike: (String) -> Unit,
|
onToggleLike: (String) -> Unit,
|
||||||
|
onMove: (Int, Int) -> Unit,
|
||||||
|
onRemove: (Int) -> Unit,
|
||||||
) {
|
) {
|
||||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
val listState = rememberLazyListState(
|
||||||
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
initialFirstVisibleItemIndex = currentIndex.coerceIn(0, tracks.lastIndex),
|
||||||
QueueRow(
|
)
|
||||||
track = track,
|
val scope = rememberCoroutineScope()
|
||||||
isCurrent = index == currentIndex,
|
|
||||||
liked = track.id in likedTrackIds,
|
// Follow the now-playing row as the track auto-advances, but only while it's
|
||||||
onClick = { onJumpTo(index) },
|
// near the visible window — if the user has scrolled away to browse, leave
|
||||||
onToggleLike = { onToggleLike(track.id) },
|
// them there (the pill offers the way back). Parity with the web queue.
|
||||||
)
|
LaunchedEffect(currentIndex) {
|
||||||
HorizontalDivider()
|
if (currentIndex < 0) return@LaunchedEffect
|
||||||
|
val visible = listState.layoutInfo.visibleItemsInfo
|
||||||
|
val first = visible.firstOrNull()?.index ?: 0
|
||||||
|
val last = visible.lastOrNull()?.index ?: 0
|
||||||
|
if (currentIndex in (first - 1)..(last + 1)) {
|
||||||
|
listState.animateScrollToItem(currentIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val currentVisible by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
listState.layoutInfo.visibleItemsInfo.any { it.index == currentIndex }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
LazyColumn(state = listState, modifier = Modifier.fillMaxSize()) {
|
||||||
|
itemsIndexed(items = tracks, key = { _, track -> track.id }) { index, track ->
|
||||||
|
QueueRow(
|
||||||
|
track = track,
|
||||||
|
index = index,
|
||||||
|
queueSize = tracks.size,
|
||||||
|
isCurrent = index == currentIndex,
|
||||||
|
liked = track.id in likedTrackIds,
|
||||||
|
onClick = { onJumpTo(index) },
|
||||||
|
onToggleLike = { onToggleLike(track.id) },
|
||||||
|
onRemove = { onRemove(index) },
|
||||||
|
onMove = onMove,
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JumpToCurrentPill(
|
||||||
|
visible = currentIndex >= 0 && !currentVisible,
|
||||||
|
onClick = {
|
||||||
|
scope.launch { listState.animateScrollToItem(currentIndex.coerceAtLeast(0)) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
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
|
@Composable
|
||||||
private fun QueueRow(
|
private fun QueueRow(
|
||||||
track: TrackRef,
|
track: TrackRef,
|
||||||
|
index: Int,
|
||||||
|
queueSize: Int,
|
||||||
isCurrent: Boolean,
|
isCurrent: Boolean,
|
||||||
liked: Boolean,
|
liked: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onToggleLike: () -> 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 {
|
||||||
@@ -116,12 +226,23 @@ private fun QueueRow(
|
|||||||
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,
|
||||||
@@ -129,26 +250,7 @@ private fun QueueRow(
|
|||||||
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),
|
||||||
@@ -157,6 +259,94 @@ private fun QueueRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
LikeButton(liked = liked, onToggle = onToggleLike)
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,4 +355,18 @@ private fun queueSubtitle(track: TrackRef): String = listOf(track.artistName, tr
|
|||||||
.filter { it.isNotEmpty() }
|
.filter { it.isNotEmpty() }
|
||||||
.joinToString(" · ")
|
.joinToString(" · ")
|
||||||
|
|
||||||
|
/** "N tracks · 12 min" header summary. */
|
||||||
|
private fun queueSummary(tracks: List<TrackRef>): String {
|
||||||
|
val minutes = tracks.sumOf { it.durationSec } / SECONDS_PER_MINUTE
|
||||||
|
val length = if (minutes >= MINUTES_PER_HOUR) {
|
||||||
|
"${minutes / MINUTES_PER_HOUR}h ${minutes % MINUTES_PER_HOUR}m"
|
||||||
|
} else {
|
||||||
|
"$minutes min"
|
||||||
|
}
|
||||||
|
val noun = if (tracks.size == 1) "track" else "tracks"
|
||||||
|
return "${tracks.size} $noun · $length"
|
||||||
|
}
|
||||||
|
|
||||||
private const val HIGHLIGHT_ALPHA = 0.12f
|
private const val HIGHLIGHT_ALPHA = 0.12f
|
||||||
|
private const val SECONDS_PER_MINUTE = 60
|
||||||
|
private const val MINUTES_PER_HOUR = 60
|
||||||
|
|||||||
+3
-3
@@ -119,9 +119,9 @@ class PlaylistsRepository @Inject constructor(
|
|||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
|
||||||
playlistTrackDao.deleteByPlaylist(id)
|
playlistTrackDao.replacePlaylistTracks(
|
||||||
playlistTrackDao.upsertAll(
|
playlistId = id,
|
||||||
wire.tracks.mapNotNull { row ->
|
rows = wire.tracks.mapNotNull { row ->
|
||||||
row.trackId?.let { trackId ->
|
row.trackId?.let { trackId ->
|
||||||
CachedPlaylistTrackEntity(
|
CachedPlaylistTrackEntity(
|
||||||
playlistId = id,
|
playlistId = id,
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package com.fabledsword.minstrel.shared
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.FlowPreview
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.debounce
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
// Once raised, the veil stays up at least this long. Without a floor a
|
||||||
|
// no-op refresh wipes on and straight back off, which reads as a glitch.
|
||||||
|
private const val DEFAULT_MIN_HOLD_MS = 900L
|
||||||
|
|
||||||
|
// The screen must stop changing for this long before the veil lowers.
|
||||||
|
// Every content change re-arms it, so a refresh that lands in stages
|
||||||
|
// (index → tile hydration → artwork) holds the veil across all of them.
|
||||||
|
private const val DEFAULT_QUIET_MS = 700L
|
||||||
|
|
||||||
|
// Hard ceiling on visibility. A refresh that never settles must not
|
||||||
|
// strand the user behind an opaque veil; the work itself is NOT capped.
|
||||||
|
private const val DEFAULT_MAX_HOLD_MS = 12_000L
|
||||||
|
|
||||||
|
// Attempts per session. Retrying behind the veil is the point: a pull
|
||||||
|
// that fails on the first try gets another go before the user sees
|
||||||
|
// anything, instead of the veil wiping off over unchanged content.
|
||||||
|
private const val DEFAULT_ATTEMPTS = 3
|
||||||
|
private const val DEFAULT_RETRY_BACKOFF_MS = 600L
|
||||||
|
|
||||||
|
/** Tunables for [UpdateVeilController]; defaults are the Home values. */
|
||||||
|
data class VeilTimings(
|
||||||
|
val minHoldMs: Long = DEFAULT_MIN_HOLD_MS,
|
||||||
|
val quietMs: Long = DEFAULT_QUIET_MS,
|
||||||
|
val maxHoldMs: Long = DEFAULT_MAX_HOLD_MS,
|
||||||
|
val attempts: Int = DEFAULT_ATTEMPTS,
|
||||||
|
val retryBackoffMs: Long = DEFAULT_RETRY_BACKOFF_MS,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A snapshot of everything that visibly moves on the veiled screen.
|
||||||
|
*
|
||||||
|
* @param contentKey any value whose equality tracks what's rendered — a
|
||||||
|
* change means the screen moved, and re-arms the quiet timer.
|
||||||
|
* @param hasContent true when real content (not a skeleton or an empty
|
||||||
|
* state) is on screen. The veil waits for this before raising: there's
|
||||||
|
* nothing to hide until there's something to hide.
|
||||||
|
* @param quiescent false while something is still landing (artwork
|
||||||
|
* loading, tiles hydrating). The veil will not lower until this is
|
||||||
|
* true, up to [VeilTimings.maxHoldMs].
|
||||||
|
*/
|
||||||
|
data class VeilSettleState(
|
||||||
|
val contentKey: Any?,
|
||||||
|
val hasContent: Boolean,
|
||||||
|
val quiescent: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** What a finished session did, so callers can report it if they want. */
|
||||||
|
enum class VeilOutcome {
|
||||||
|
/** Content changed, and the veil covered the churn. */
|
||||||
|
CHANGED,
|
||||||
|
|
||||||
|
/** The refresh worked, but nothing on screen moved — already current. */
|
||||||
|
UNCHANGED,
|
||||||
|
|
||||||
|
/** Every attempt failed. */
|
||||||
|
FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One session's result, plus whether a user explicitly asked for it.
|
||||||
|
*
|
||||||
|
* [userInitiated] is what lets a caller tell feedback from noise: a user
|
||||||
|
* who pulled to refresh is owed an answer even when the answer is "nothing
|
||||||
|
* changed", while the same outcome from a background check is noise.
|
||||||
|
*/
|
||||||
|
data class VeilSessionResult(
|
||||||
|
val outcome: VeilOutcome,
|
||||||
|
val userInitiated: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives an "updating" overlay from *observed content change and settling*
|
||||||
|
* rather than from a fixed delay.
|
||||||
|
*
|
||||||
|
* The problem this replaces: a veil held for `refresh().join() + 500ms`
|
||||||
|
* lowers while the screen is still moving, because finishing the network
|
||||||
|
* pull is nowhere near the end of the visible work — the pull writes id
|
||||||
|
* lists, then tiles hydrate one by one, then artwork loads. And a plain
|
||||||
|
* `isUpdating` Boolean set in a `finally` gets cleared by whichever of
|
||||||
|
* two overlapping refreshes finishes first, wiping the veil off mid-update
|
||||||
|
* (issue #2327).
|
||||||
|
*
|
||||||
|
* So instead: run [work], raise only if the content actually changes, then
|
||||||
|
* hold until [settleSignal] reports the screen has stopped changing for
|
||||||
|
* [VeilTimings.quietMs] AND is quiescent — bounded below by
|
||||||
|
* [VeilTimings.minHoldMs] so it can never flash, and above by
|
||||||
|
* [VeilTimings.maxHoldMs] so it can never strand.
|
||||||
|
*
|
||||||
|
* The raise is deliberately *reactive*: a refresh that returns what's
|
||||||
|
* already on screen — the common case on a launch over a warm cache —
|
||||||
|
* raises nothing at all, because a veil over an unchanged screen hides
|
||||||
|
* nothing and only delays first paint. The cost is that the veil arrives
|
||||||
|
* one emission after the change, so a single atomic content swap shows
|
||||||
|
* through; everything messier that follows it (tile hydration, then
|
||||||
|
* artwork) still lands behind the veil.
|
||||||
|
*
|
||||||
|
* Overlapping triggers extend the running session instead of racing it,
|
||||||
|
* so the veil stays up continuously rather than lowering and re-raising.
|
||||||
|
*
|
||||||
|
* Failure is quiet at this layer: [work] gets [VeilTimings.attempts] tries
|
||||||
|
* behind the veil, and if they all fail the veil simply wipes off over the
|
||||||
|
* cached content. Giving up ends only *this* session — it sets no latch and
|
||||||
|
* blocks nothing, so the caller's own recovery paths (reconnect re-pull,
|
||||||
|
* freshness sweeps, the next event, a manual pull) keep retrying afterwards
|
||||||
|
* exactly as before. Callers that want to surface a failure can do it from
|
||||||
|
* [onSessionEnd] instead.
|
||||||
|
*
|
||||||
|
* @param work one refresh attempt; returns true when it succeeded.
|
||||||
|
* @param shouldVeil sampled at session start — "is there cached content
|
||||||
|
* this refresh is about to overwrite?". False means a cold load, where
|
||||||
|
* a skeleton is the right affordance, and the work runs unveiled.
|
||||||
|
* @param onSessionEnd called once per finished session, on the controller's
|
||||||
|
* coroutine. Use it for user-facing feedback the veil itself can't give.
|
||||||
|
*/
|
||||||
|
class UpdateVeilController(
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val settleSignal: Flow<VeilSettleState>,
|
||||||
|
private val shouldVeil: suspend () -> Boolean,
|
||||||
|
private val timings: VeilTimings = VeilTimings(),
|
||||||
|
private val onSessionEnd: (VeilSessionResult) -> Unit = {},
|
||||||
|
private val work: suspend () -> Boolean,
|
||||||
|
) {
|
||||||
|
private val visibleInternal = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/** True while the veil should be drawn over the screen. */
|
||||||
|
val visible: StateFlow<Boolean> = visibleInternal.asStateFlow()
|
||||||
|
|
||||||
|
private val finishedInternal = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments as each session ends. Lets a caller wait for "this
|
||||||
|
* refresh is done" without knowing whether a veil ever went up —
|
||||||
|
* a pull-to-refresh indicator needs exactly that, since an unchanged
|
||||||
|
* refresh never raises one.
|
||||||
|
*/
|
||||||
|
val finishedSessions: StateFlow<Int> = finishedInternal.asStateFlow()
|
||||||
|
|
||||||
|
// Conflated: a burst of triggers (reconnect + rebuild event arriving
|
||||||
|
// together) collapses into one follow-up pass, not a queue of them.
|
||||||
|
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||||
|
|
||||||
|
// Sticky across a conflated burst: conflation drops the older token, so
|
||||||
|
// the "a user asked for this" bit can't ride on it. If ANY coalesced
|
||||||
|
// trigger was the user's, the session still owes them an answer.
|
||||||
|
private val userAsked = AtomicBoolean(false)
|
||||||
|
|
||||||
|
init {
|
||||||
|
// One consumer, so sessions are serialised by construction: two
|
||||||
|
// triggers can never each own a piece of the veil's state.
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
requests.receive()
|
||||||
|
runSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask for a refresh. Safe to call from any trigger at any rate —
|
||||||
|
* calls arriving during a session extend it rather than starting a
|
||||||
|
* competing one.
|
||||||
|
*
|
||||||
|
* @param userInitiated true when a person explicitly asked (pull to
|
||||||
|
* refresh, a Retry button), which is what [VeilSessionResult] carries
|
||||||
|
* through to [onSessionEnd].
|
||||||
|
*/
|
||||||
|
fun request(userInitiated: Boolean = false) {
|
||||||
|
if (userInitiated) userAsked.set(true)
|
||||||
|
requests.trySend(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runSession() {
|
||||||
|
// Sampled at both ends of the work: a trigger folded in mid-session
|
||||||
|
// (see [drainWork]) may have been the user's, and they're still owed
|
||||||
|
// an answer for it.
|
||||||
|
val askedAtStart = userAsked.getAndSet(false)
|
||||||
|
if (!shouldVeil()) {
|
||||||
|
// Cold load: the skeleton is the right affordance, so no veil.
|
||||||
|
// Succeeding here did change the screen — from nothing to
|
||||||
|
// something — so it reports CHANGED, never "already up to date".
|
||||||
|
val ok = drainWork()
|
||||||
|
finish(succeeded = ok, changed = ok, userInitiated = askedAtStart)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val raised = CompletableDeferred<Unit>()
|
||||||
|
val raiser = scope.launch { raiseWhenContentChanges(raised) }
|
||||||
|
// Floor and ceiling are measured from the raise, not the request,
|
||||||
|
// so a late raise still gets its full no-flash minimum.
|
||||||
|
val floor = scope.launch {
|
||||||
|
raised.await()
|
||||||
|
delay(timings.minHoldMs)
|
||||||
|
}
|
||||||
|
val ceiling = scope.launch {
|
||||||
|
raised.await()
|
||||||
|
delay(timings.maxHoldMs)
|
||||||
|
visibleInternal.value = false
|
||||||
|
}
|
||||||
|
var succeeded = false
|
||||||
|
try {
|
||||||
|
succeeded = drainWork()
|
||||||
|
// Always wait for the settle, never conditionally on `visible`:
|
||||||
|
// work that finishes without suspending would otherwise reach
|
||||||
|
// here before the raiser has been dispatched, tear the session
|
||||||
|
// down, and leave the churn uncovered. This wait is also what
|
||||||
|
// makes `raised.isCompleted` below a trustworthy "did anything
|
||||||
|
// change?" — a change landing just after the pull returns still
|
||||||
|
// gets seen.
|
||||||
|
withTimeoutOrNull(timings.maxHoldMs) { awaitSettled() }
|
||||||
|
// Honour the no-flash minimum before lowering. Deliberately in
|
||||||
|
// the try and not the finally: on cancellation the scope is
|
||||||
|
// going away and nothing will render the veil, so the floor is
|
||||||
|
// pointless there — and a finally that suspends is a finally
|
||||||
|
// that can resist teardown.
|
||||||
|
if (raised.isCompleted) floor.join()
|
||||||
|
} finally {
|
||||||
|
raiser.cancel()
|
||||||
|
ceiling.cancel()
|
||||||
|
floor.cancel()
|
||||||
|
visibleInternal.value = false
|
||||||
|
finish(
|
||||||
|
succeeded = succeeded,
|
||||||
|
changed = raised.isCompleted,
|
||||||
|
userInitiated = askedAtStart,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Raises the veil the moment the screen's content differs from what was
|
||||||
|
* already on it — and never, if this refresh turns out to be a no-op.
|
||||||
|
*
|
||||||
|
* The baseline is the first state that HAS content, not simply the first
|
||||||
|
* state: over a warm cache the cached rows paint a moment after the
|
||||||
|
* session starts, and treating that first paint as "a change" would veil
|
||||||
|
* every launch, which is the whole thing this avoids.
|
||||||
|
*/
|
||||||
|
private suspend fun raiseWhenContentChanges(raised: CompletableDeferred<Unit>) {
|
||||||
|
val baseline = settleSignal.first { it.hasContent }
|
||||||
|
settleSignal.first { it.hasContent && it.contentKey != baseline.contentKey }
|
||||||
|
visibleInternal.value = true
|
||||||
|
raised.complete(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finish(succeeded: Boolean, changed: Boolean, userInitiated: Boolean) {
|
||||||
|
val outcome = when {
|
||||||
|
!succeeded -> VeilOutcome.FAILED
|
||||||
|
changed -> VeilOutcome.CHANGED
|
||||||
|
else -> VeilOutcome.UNCHANGED
|
||||||
|
}
|
||||||
|
onSessionEnd(
|
||||||
|
VeilSessionResult(
|
||||||
|
outcome = outcome,
|
||||||
|
// Fold in a mid-session request from the user.
|
||||||
|
userInitiated = userInitiated || userAsked.getAndSet(false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finishedInternal.update { it + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the refresh eventually succeeded. */
|
||||||
|
private suspend fun drainWork(): Boolean {
|
||||||
|
var succeeded: Boolean
|
||||||
|
do {
|
||||||
|
succeeded = runWorkWithRetries()
|
||||||
|
// A trigger that arrived mid-session gets folded into this one.
|
||||||
|
} while (requests.tryReceive().isSuccess)
|
||||||
|
return succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runWorkWithRetries(): Boolean {
|
||||||
|
repeat(timings.attempts) { attempt ->
|
||||||
|
if (work()) return true
|
||||||
|
if (attempt < timings.attempts - 1) {
|
||||||
|
delay(timings.retryBackoffMs * (attempt + 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Suspends until the screen has been unchanged for
|
||||||
|
* [VeilTimings.quietMs] and reports itself quiescent.
|
||||||
|
*
|
||||||
|
* `debounce` is what makes this hold across a staged update: every
|
||||||
|
* change restarts the window, so the veil lowers only once emissions
|
||||||
|
* actually stop. `first { quiescent }` then rejects a quiet-but-
|
||||||
|
* still-loading moment and waits for the next lull.
|
||||||
|
*/
|
||||||
|
@OptIn(FlowPreview::class)
|
||||||
|
private suspend fun awaitSettled() {
|
||||||
|
settleSignal
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.debounce(timings.quietMs)
|
||||||
|
.first { it.quiescent }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts the cover-art loads that are currently in flight, so a
|
||||||
|
* screen-level overlay can wait for the artwork to actually land instead
|
||||||
|
* of guessing with a fixed delay.
|
||||||
|
*
|
||||||
|
* Artwork is the most visible pop-in on Home: a tile can be fully
|
||||||
|
* hydrated (title, artist, counts all present) and still snap its cover
|
||||||
|
* in a second later, which is exactly the churn the "Updating your
|
||||||
|
* mixes…" veil exists to hide. The refresh coroutine can't see that —
|
||||||
|
* it finishes long before Coil does — so the composition reports it
|
||||||
|
* upward here instead.
|
||||||
|
*
|
||||||
|
* [ServerImage] reports into whatever tracker it finds in
|
||||||
|
* [LocalArtSettleTracker], which means every art surface in the app
|
||||||
|
* participates for free. Only *composed* images are counted, so a
|
||||||
|
* LazyRow's off-screen tiles are correctly ignored — the count tracks
|
||||||
|
* the pop-in a user can actually see.
|
||||||
|
*
|
||||||
|
* Provide one per screen that needs it (typically owned by the
|
||||||
|
* screen's ViewModel so its refresh logic can read [inFlight]):
|
||||||
|
*
|
||||||
|
* CompositionLocalProvider(LocalArtSettleTracker provides vm.artTracker) { ... }
|
||||||
|
*/
|
||||||
|
@Stable
|
||||||
|
class ArtSettleTracker {
|
||||||
|
private val inFlightInternal = MutableStateFlow(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many on-screen images are still loading. Zero means the
|
||||||
|
* artwork has settled — every composed cover has either drawn or
|
||||||
|
* failed to a fallback.
|
||||||
|
*/
|
||||||
|
val inFlight: StateFlow<Int> = inFlightInternal.asStateFlow()
|
||||||
|
|
||||||
|
fun begin() {
|
||||||
|
inFlightInternal.update { it + 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun end() {
|
||||||
|
// Floor at zero: a decrement can outlive its increment when a
|
||||||
|
// tile is disposed mid-load and the count must not go negative
|
||||||
|
// and wedge "settled" off forever.
|
||||||
|
inFlightInternal.update { (it - 1).coerceAtLeast(0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tracker [ServerImage] reports load state to, or null on screens
|
||||||
|
* that don't care (the default) — reporting is then a no-op.
|
||||||
|
*/
|
||||||
|
val LocalArtSettleTracker = staticCompositionLocalOf<ArtSettleTracker?> { null }
|
||||||
@@ -1,25 +1,40 @@
|
|||||||
package com.fabledsword.minstrel.shared.widgets
|
package com.fabledsword.minstrel.shared.widgets
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.compose.AsyncImagePainter
|
import coil3.compose.AsyncImagePainter
|
||||||
import com.fabledsword.minstrel.shared.resolveServerUrl
|
import com.fabledsword.minstrel.shared.resolveServerUrl
|
||||||
|
|
||||||
|
// The fallback fades out as the artwork crossfades in (Coil's crossfade is
|
||||||
|
// configured globally on the ImageLoader in MinstrelApplication). Matching
|
||||||
|
// durations makes the swap read as one cross-dissolve; without the fade the
|
||||||
|
// placeholder icon vanished a frame before the cover appeared, which is the
|
||||||
|
// "art popping in" the Home veil exists to hide (issue #2327).
|
||||||
|
private const val FALLBACK_FADE_MS = 220
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a server-hosted image, resolving relative URLs centrally so
|
* Renders a server-hosted image, resolving relative URLs centrally so
|
||||||
* every cover surface loads consistently. Shows [fallback] when the URL
|
* every cover surface loads consistently. Shows [fallback] when the URL
|
||||||
* is blank/unresolvable, while the image is still loading, and when the
|
* is blank/unresolvable, while the image is still loading, and when the
|
||||||
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
* load fails — so a tile is never left blank (e.g. art not yet backfilled,
|
||||||
* which the "You might like" row hits often).
|
* which the "You might like" row hits often).
|
||||||
|
*
|
||||||
|
* In-flight loads are reported to [LocalArtSettleTracker] when a screen
|
||||||
|
* provides one, so a screen-level overlay can wait for artwork to land
|
||||||
|
* instead of guessing with a fixed delay.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ServerImage(
|
fun ServerImage(
|
||||||
@@ -40,6 +55,23 @@ fun ServerImage(
|
|||||||
var state by remember(resolved) {
|
var state by remember(resolved) {
|
||||||
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
mutableStateOf<AsyncImagePainter.State>(AsyncImagePainter.State.Empty)
|
||||||
}
|
}
|
||||||
|
// Empty counts as loading: it's the pre-request state, so treating it
|
||||||
|
// as settled would let a screen overlay lower before Coil even starts.
|
||||||
|
val loading = state is AsyncImagePainter.State.Empty ||
|
||||||
|
state is AsyncImagePainter.State.Loading
|
||||||
|
val tracker = LocalArtSettleTracker.current
|
||||||
|
DisposableEffect(tracker, loading) {
|
||||||
|
if (loading) tracker?.begin()
|
||||||
|
// Balanced by construction: the effect re-runs when `loading` flips
|
||||||
|
// (decrement, then no re-increment) and disposes when a tile leaves
|
||||||
|
// the composition mid-load (scrolled away).
|
||||||
|
onDispose { if (loading) tracker?.end() }
|
||||||
|
}
|
||||||
|
val fallbackAlpha by animateFloatAsState(
|
||||||
|
targetValue = if (loading || state is AsyncImagePainter.State.Error) 1f else 0f,
|
||||||
|
animationSpec = tween(FALLBACK_FADE_MS),
|
||||||
|
label = "art-fallback",
|
||||||
|
)
|
||||||
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
Box(modifier = modifier, contentAlignment = Alignment.Center) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = resolved,
|
model = resolved,
|
||||||
@@ -48,10 +80,10 @@ fun ServerImage(
|
|||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
onState = { state = it },
|
onState = { state = it },
|
||||||
)
|
)
|
||||||
if (state is AsyncImagePainter.State.Loading ||
|
if (fallbackAlpha > 0f) {
|
||||||
state is AsyncImagePainter.State.Error
|
Box(Modifier.alpha(fallbackAlpha), contentAlignment = Alignment.Center) {
|
||||||
) {
|
fallback()
|
||||||
fallback()
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+132
@@ -0,0 +1,132 @@
|
|||||||
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse rules for desired-state toggles in the offline queue.
|
||||||
|
*
|
||||||
|
* The hazard this guards against is real and silent: without collapsing, a
|
||||||
|
* queued snooze that replays AFTER the user's undo re-hides an artist they
|
||||||
|
* asked to see again, and nothing surfaces the contradiction.
|
||||||
|
*/
|
||||||
|
class SupersededToggleIdsTest {
|
||||||
|
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
private fun snoozeRow(id: Long, mbid: String, desiredSnoozed: Boolean) = CachedMutationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
SuggestionSnoozeTogglePayload.serializer(),
|
||||||
|
SuggestionSnoozeTogglePayload(mbid, "Name", desiredSnoozed),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun likeRow(id: Long, entityId: String, desired: Boolean) = CachedMutationEntity(
|
||||||
|
id = id,
|
||||||
|
kind = MutationKind.LIKE_TOGGLE,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
LikeTogglePayload.serializer(),
|
||||||
|
LikeTogglePayload("artist", entityId, desired),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a snooze followed by its undo drops the snooze`() {
|
||||||
|
val rows = listOf(
|
||||||
|
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = false),
|
||||||
|
)
|
||||||
|
// Only the later intent (the undo) survives to be replayed.
|
||||||
|
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `toggles for different candidates never collapse into each other`() {
|
||||||
|
val rows = listOf(
|
||||||
|
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||||
|
snoozeRow(2, "mb-b", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `only the newest of several toggles for one candidate survives`() {
|
||||||
|
val rows = listOf(
|
||||||
|
snoozeRow(1, "mb-a", desiredSnoozed = true),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = false),
|
||||||
|
snoozeRow(3, "mb-a", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertEquals(setOf(1L, 2L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The kind is part of the collapse key, so a snooze and a like that happen
|
||||||
|
// to share an id string must not shadow one another.
|
||||||
|
@Test
|
||||||
|
fun `a like and a snooze on the same id string do not collide`() {
|
||||||
|
val rows = listOf(
|
||||||
|
likeRow(1, "same-id", desired = true),
|
||||||
|
snoozeRow(2, "same-id", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `like toggles still collapse — the pre-existing behaviour is intact`() {
|
||||||
|
val rows = listOf(
|
||||||
|
likeRow(1, "artist-1", desired = true),
|
||||||
|
likeRow(2, "artist-1", desired = false),
|
||||||
|
)
|
||||||
|
assertEquals(setOf(1L), supersededToggleIds(rows, json))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `non-toggle kinds are never collapsed, even repeated for one entity`() {
|
||||||
|
// Two appends to the same playlist are two real actions, not one
|
||||||
|
// desired state — collapsing them would lose a write.
|
||||||
|
val rows = listOf(
|
||||||
|
CachedMutationEntity(
|
||||||
|
id = 1,
|
||||||
|
kind = MutationKind.PLAYLIST_APPEND,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
PlaylistAppendPayload.serializer(),
|
||||||
|
PlaylistAppendPayload("pl-1", listOf("t1")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
CachedMutationEntity(
|
||||||
|
id = 2,
|
||||||
|
kind = MutationKind.PLAYLIST_APPEND,
|
||||||
|
payload = json.encodeToString(
|
||||||
|
PlaylistAppendPayload.serializer(),
|
||||||
|
PlaylistAppendPayload("pl-1", listOf("t2")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row whose payload won't decode gets no key at all, rather than sharing
|
||||||
|
// a "corrupt" bucket — otherwise one bad row could suppress a good one
|
||||||
|
// behind it. The dispatcher DROPs the bad row on its own.
|
||||||
|
@Test
|
||||||
|
fun `an undecodable payload does not suppress a valid later row`() {
|
||||||
|
val rows = listOf(
|
||||||
|
CachedMutationEntity(
|
||||||
|
id = 1,
|
||||||
|
kind = MutationKind.SUGGESTION_SNOOZE_TOGGLE,
|
||||||
|
payload = "{ not json",
|
||||||
|
),
|
||||||
|
snoozeRow(2, "mb-a", desiredSnoozed = true),
|
||||||
|
)
|
||||||
|
assertTrue(supersededToggleIds(rows, json).isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty queue collapses nothing`() {
|
||||||
|
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card's subtitle line (#2377). Wording is kept in lockstep with the web
|
||||||
|
* client's reasonText() in SuggestionFeed.svelte — these assertions are the
|
||||||
|
* record of what that wording IS, so a change on one client without the other
|
||||||
|
* shows up as a failure rather than as silent divergence between the two
|
||||||
|
* surfaces.
|
||||||
|
*/
|
||||||
|
class ArtistSuggestionReasonTest {
|
||||||
|
|
||||||
|
private val seeds = listOf(
|
||||||
|
SeedContributionRef(name = "Seed", isLiked = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun suggestion(
|
||||||
|
matched: List<String> = emptyList(),
|
||||||
|
attribution: List<SeedContributionRef> = seeds,
|
||||||
|
) = ArtistSuggestionRef(
|
||||||
|
mbid = "mb",
|
||||||
|
name = "Candidate",
|
||||||
|
attribution = attribution,
|
||||||
|
matchedTags = matched,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `one matched tag reads in the singular`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in shoegaze.",
|
||||||
|
suggestion(matched = listOf("shoegaze")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `two matched tags join with and`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in shoegaze and dream pop.",
|
||||||
|
suggestion(matched = listOf("shoegaze", "dream pop")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `three matched tags use an Oxford comma`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in a, b, and c.",
|
||||||
|
suggestion(matched = listOf("a", "b", "c")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server caps at 3, but the client must not render a run-on line if a
|
||||||
|
// future server sends more.
|
||||||
|
@Test
|
||||||
|
fun `more than three matched tags are capped at three`() {
|
||||||
|
assertEquals(
|
||||||
|
"Matches your taste in a, b, and c.",
|
||||||
|
suggestion(matched = listOf("a", "b", "c", "d", "e")).reasonText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The COMMON case: most candidates have no cached tags (#2376), so the card
|
||||||
|
// must fall back to seed attribution rather than going blank.
|
||||||
|
@Test
|
||||||
|
fun `no matched tags falls back to seed attribution`() {
|
||||||
|
assertEquals("Because you liked Seed.", suggestion().reasonText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing to say at all — a candidate with neither tags nor attribution
|
||||||
|
// yields an empty line, which the tile suppresses rather than rendering as
|
||||||
|
// a blank row.
|
||||||
|
@Test
|
||||||
|
fun `neither tags nor attribution yields an empty line`() {
|
||||||
|
assertEquals("", suggestion(attribution = emptyList()).reasonText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The taste reason WINS over attribution when both exist: describing the
|
||||||
|
// music beats describing the similarity graph.
|
||||||
|
@Test
|
||||||
|
fun `a taste match supersedes seed attribution`() {
|
||||||
|
val got = suggestion(matched = listOf("shoegaze")).reasonText
|
||||||
|
assertEquals("Matches your taste in shoegaze.", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
import kotlinx.datetime.Instant
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `returnsIn` phrasing for the parked-suggestions list (#2375). The clock is
|
||||||
|
* injected rather than frozen, so these assertions are stable.
|
||||||
|
*/
|
||||||
|
class SuggestionSnoozeRefTest {
|
||||||
|
|
||||||
|
private val now = 1_800_000_000_000L // fixed epoch ms; any value works
|
||||||
|
|
||||||
|
private fun snoozeIn(days: Double) = SuggestionSnoozeRef(
|
||||||
|
mbid = "mb",
|
||||||
|
name = "Parked",
|
||||||
|
snoozedUntil = Instant
|
||||||
|
.fromEpochMilliseconds(now + (days * 86_400_000L).toLong())
|
||||||
|
.toString(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `the default 90-day snooze reads as about 3 months`() {
|
||||||
|
assertEquals("in about 3 months", snoozeIn(90.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a month reads in the singular`() {
|
||||||
|
assertEquals("in about a month", snoozeIn(30.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `under the month threshold it counts days`() {
|
||||||
|
assertEquals("in 14 days", snoozeIn(14.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pins the days→months boundary. The singular branch was originally dead
|
||||||
|
// code because the threshold (45) sat above the divisor (30), so no day
|
||||||
|
// count could ever round to one month without being caught by the days
|
||||||
|
// branch first. Asserting both sides of the seam keeps that from
|
||||||
|
// regressing silently.
|
||||||
|
@Test
|
||||||
|
fun `the days-to-months boundary is exactly at 30 days`() {
|
||||||
|
assertEquals("in 29 days", snoozeIn(29.0).returnsIn(now))
|
||||||
|
assertEquals("in about a month", snoozeIn(30.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `well past a month still reads in the singular rather than jumping to two`() {
|
||||||
|
assertEquals("in about a month", snoozeIn(40.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `tomorrow is named, not rendered as 1 days`() {
|
||||||
|
assertEquals("tomorrow", snoozeIn(1.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `later today rounds down to today rather than going negative`() {
|
||||||
|
assertEquals("today", snoozeIn(0.1).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The server only ever returns unexpired rows, so a past timestamp means
|
||||||
|
// our clock and the server's disagree. The row is on screen either way, so
|
||||||
|
// say something plausible rather than leaving the line blank.
|
||||||
|
@Test
|
||||||
|
fun `an already-past expiry degrades to shortly`() {
|
||||||
|
assertEquals("shortly", snoozeIn(-5.0).returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unparseable timestamp degrades to shortly`() {
|
||||||
|
val row = SuggestionSnoozeRef(mbid = "mb", name = "Parked", snoozedUntil = "not-a-date")
|
||||||
|
assertEquals("shortly", row.returnsIn(now))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an empty timestamp degrades to shortly`() {
|
||||||
|
val row = SuggestionSnoozeRef(mbid = "mb", name = "Parked", snoozedUntil = "")
|
||||||
|
assertEquals("shortly", row.returnsIn(now))
|
||||||
|
}
|
||||||
|
}
|
||||||
+314
@@ -0,0 +1,314 @@
|
|||||||
|
package com.fabledsword.minstrel.shared
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
private const val WORK_MS = 1_000L
|
||||||
|
private const val CHURN_ROUNDS = 5
|
||||||
|
private const val ART_IN_FLIGHT = 3
|
||||||
|
private const val SUCCEED_ON_ATTEMPT = 3
|
||||||
|
private const val QUIET_WINDOWS_TO_OUTLAST = 3
|
||||||
|
|
||||||
|
// Time to let a session finish once the screen has stopped changing: the
|
||||||
|
// retry backoffs, the quiet window and the minimum hold all fit inside it,
|
||||||
|
// while staying well under maxHoldMs. That gap matters — if a drain ran
|
||||||
|
// past the ceiling, "the veil lowered" would no longer distinguish
|
||||||
|
// "it settled" from "it gave up", which is the whole point of these tests.
|
||||||
|
private const val DRAIN_MS = 3_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The veil's job is to go up only when content actually changes, and then to
|
||||||
|
* stay up until the screen has stopped moving. Each test pins one of the ways
|
||||||
|
* the original fixed-delay implementation got that wrong (issue #2327).
|
||||||
|
*
|
||||||
|
* The controller is built on `backgroundScope` throughout: its consumer
|
||||||
|
* loop runs forever, so hanging it off the test's own scope would stop
|
||||||
|
* `runTest` from ever completing.
|
||||||
|
*
|
||||||
|
* Consequence, and the reason every wait below is an explicit
|
||||||
|
* `advanceTimeBy`: **`advanceUntilIdle()` is useless here.** It advances
|
||||||
|
* only while *foreground* work remains, and everything this controller
|
||||||
|
* does lives in `backgroundScope` — so it returns having run nothing, and
|
||||||
|
* assertions land on a session that never started (CI run 3163 failed all
|
||||||
|
* seven of these with "expected 3, actual 0" and friends). Drive the clock
|
||||||
|
* deliberately instead; don't "simplify" these back to advanceUntilIdle.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class UpdateVeilControllerTest {
|
||||||
|
|
||||||
|
private val timings = VeilTimings(
|
||||||
|
minHoldMs = 900,
|
||||||
|
quietMs = 700,
|
||||||
|
maxHoldMs = 12_000,
|
||||||
|
attempts = 3,
|
||||||
|
retryBackoffMs = 600,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Drives the settle signal by hand: content key, presence, art count. */
|
||||||
|
private class FakeScreen(hasContent: Boolean = true) {
|
||||||
|
val state = MutableStateFlow(Triple(0, hasContent, 0))
|
||||||
|
|
||||||
|
val signal = state.map { (key, hasContent, art) ->
|
||||||
|
VeilSettleState(contentKey = key, hasContent = hasContent, quiescent = art == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Content visibly changed — what the veil exists to cover. */
|
||||||
|
fun churn() {
|
||||||
|
state.value = state.value.copy(first = state.value.first + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun artLoading(count: Int) {
|
||||||
|
state.value = state.value.copy(third = count)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contentAppears() {
|
||||||
|
state.value = state.value.copy(second = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun TestScope.controllerOn(
|
||||||
|
screen: FakeScreen,
|
||||||
|
shouldVeil: suspend () -> Boolean = { true },
|
||||||
|
onSessionEnd: (VeilSessionResult) -> Unit = {},
|
||||||
|
work: suspend () -> Boolean,
|
||||||
|
) = UpdateVeilController(
|
||||||
|
scope = backgroundScope,
|
||||||
|
settleSignal = screen.signal,
|
||||||
|
shouldVeil = shouldVeil,
|
||||||
|
timings = timings,
|
||||||
|
onSessionEnd = onSessionEnd,
|
||||||
|
work = work,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Records every visibility transition, so an extra raise can't hide. */
|
||||||
|
private fun TestScope.recordVisibility(controller: UpdateVeilController): List<Boolean> {
|
||||||
|
val seen = mutableListOf<Boolean>()
|
||||||
|
backgroundScope.launch { controller.visible.collect { seen.add(it) } }
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `veil outlasts content that keeps churning after the pull returns`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val controller = controllerOn(screen) { true }
|
||||||
|
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
// The pull's write lands: content changed, so the veil goes up.
|
||||||
|
screen.churn()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(controller.visible.value, "veil is up while the screen is still moving")
|
||||||
|
|
||||||
|
// Tiles hydrating one after another, each inside the quiet window.
|
||||||
|
// The old implementation had already wiped off after a flat 500ms.
|
||||||
|
repeat(CHURN_ROUNDS) {
|
||||||
|
advanceTimeBy(timings.quietMs / 2)
|
||||||
|
screen.churn()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(controller.visible.value, "veil must hold across staged churn")
|
||||||
|
}
|
||||||
|
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertFalse(controller.visible.value, "veil lowers once the screen goes quiet")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `veil waits for artwork to finish loading`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val controller = controllerOn(screen) { true }
|
||||||
|
|
||||||
|
screen.artLoading(ART_IN_FLIGHT)
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
screen.churn()
|
||||||
|
runCurrent()
|
||||||
|
// Well past the quiet window and the floor — but art is still in
|
||||||
|
// flight, so lowering now would show the covers popping in.
|
||||||
|
advanceTimeBy(timings.minHoldMs + timings.quietMs * QUIET_WINDOWS_TO_OUTLAST)
|
||||||
|
assertTrue(controller.visible.value, "veil must wait on in-flight art")
|
||||||
|
|
||||||
|
screen.artLoading(0)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertFalse(controller.visible.value, "veil lowers once art has landed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retries quietly, then veils the churn the successful attempt produces`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
var attempts = 0
|
||||||
|
val controller = controllerOn(screen) {
|
||||||
|
attempts++
|
||||||
|
val succeeded = attempts >= SUCCEED_ON_ATTEMPT // fail twice
|
||||||
|
// Only a pull that worked writes anything.
|
||||||
|
if (succeeded) screen.churn()
|
||||||
|
succeeded
|
||||||
|
}
|
||||||
|
val seen = recordVisibility(controller)
|
||||||
|
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
// A failed pull changes nothing, so there is nothing to hide yet —
|
||||||
|
// the retries happen with no veil at all.
|
||||||
|
assertFalse(controller.visible.value, "no veil over a pull that changed nothing")
|
||||||
|
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(SUCCEED_ON_ATTEMPT, attempts, "retries until the pull succeeds")
|
||||||
|
assertEquals(
|
||||||
|
listOf(false, true, false),
|
||||||
|
seen,
|
||||||
|
"the veil went up once, over the churn the retry finally produced",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `giving up is silent, reports FAILED, and does not block later requests`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
var attempts = 0
|
||||||
|
var succeed = false
|
||||||
|
val controller = controllerOn(screen, onSessionEnd = { results += it }) {
|
||||||
|
attempts++
|
||||||
|
succeed
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(timings.attempts, attempts, "exhausts its attempts")
|
||||||
|
assertFalse(controller.visible.value, "no veil — a failed pull changed nothing")
|
||||||
|
assertEquals(VeilOutcome.FAILED, results.single().outcome)
|
||||||
|
assertTrue(results.single().userInitiated, "the user asked, so they're owed an answer")
|
||||||
|
|
||||||
|
// Giving up must not latch anything off — the reconnect-driven
|
||||||
|
// recovery still gets to try again later.
|
||||||
|
succeed = true
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(timings.attempts + 1, attempts, "a later request still runs")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `overlapping requests extend one veil instead of racing it`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
var started = 0
|
||||||
|
val controller = controllerOn(screen) {
|
||||||
|
started++
|
||||||
|
delay(WORK_MS)
|
||||||
|
screen.churn()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
val seen = recordVisibility(controller)
|
||||||
|
|
||||||
|
// Reconnect and the rebuild event arriving together is what made the
|
||||||
|
// old Boolean flag clear mid-update: whichever pull finished first
|
||||||
|
// wiped the veil off while the other was still running.
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(WORK_MS + 1)
|
||||||
|
assertTrue(controller.visible.value, "second trigger extends the same veil")
|
||||||
|
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
assertEquals(2, started, "the mid-session trigger still did its pull")
|
||||||
|
assertEquals(listOf(false, true, false), seen, "one veil session, not two")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a never-settling screen still releases the veil at the ceiling`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val controller = controllerOn(screen) {
|
||||||
|
screen.churn()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
screen.artLoading(1) // an image that never completes
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(timings.maxHoldMs + 1)
|
||||||
|
assertFalse(controller.visible.value, "the hard ceiling must never strand the user")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unchanged refresh never raises the veil and reports UNCHANGED`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
// Succeeds without writing anything — the common case on a launch
|
||||||
|
// over a warm cache, where the server returns what's already cached.
|
||||||
|
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
|
||||||
|
val seen = recordVisibility(controller)
|
||||||
|
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertEquals(listOf(false), seen, "a veil over an unchanged screen would hide nothing")
|
||||||
|
assertEquals(VeilOutcome.UNCHANGED, results.single().outcome)
|
||||||
|
assertTrue(results.single().userInitiated, "so the caller can say 'already up to date'")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cached content painting is not mistaken for a change`() = runTest {
|
||||||
|
// Warm cache that hasn't painted yet: the rows arrive a moment after
|
||||||
|
// the session starts. Treating that first paint as churn would veil
|
||||||
|
// every single launch.
|
||||||
|
val screen = FakeScreen(hasContent = false)
|
||||||
|
val controller = controllerOn(screen) { true }
|
||||||
|
|
||||||
|
controller.request()
|
||||||
|
runCurrent()
|
||||||
|
screen.contentAppears()
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertFalse(controller.visible.value, "first paint is not churn")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a background trigger coalescing with the user's does not swallow their answer`() =
|
||||||
|
runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
val controller = controllerOn(screen, onSessionEnd = { results += it }) { true }
|
||||||
|
|
||||||
|
// Conflation drops the older token, so the "a user asked" bit
|
||||||
|
// cannot ride on it — it's tracked separately for exactly this.
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
controller.request()
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
results.first().userInitiated,
|
||||||
|
"the user's request must not be conflated away",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cold load runs unveiled and is never reported as already up to date`() = runTest {
|
||||||
|
val screen = FakeScreen()
|
||||||
|
val results = mutableListOf<VeilSessionResult>()
|
||||||
|
var ran = false
|
||||||
|
val controller = controllerOn(
|
||||||
|
screen,
|
||||||
|
shouldVeil = { false }, // empty cache: the skeleton owns this
|
||||||
|
onSessionEnd = { results += it },
|
||||||
|
) {
|
||||||
|
ran = true
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.request(userInitiated = true)
|
||||||
|
advanceTimeBy(DRAIN_MS)
|
||||||
|
|
||||||
|
assertTrue(ran, "the refresh still happens")
|
||||||
|
assertFalse(controller.visible.value, "but no veil over a skeleton")
|
||||||
|
// It went from nothing to something — that IS a change.
|
||||||
|
assertEquals(VeilOutcome.CHANGED, results.single().outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-11
@@ -9,11 +9,17 @@ Minstrel's four workflows consume two CI images:
|
|||||||
|
|
||||||
```
|
```
|
||||||
git.fabledsword.com/bvandeusen/ci-go:1.26
|
git.fabledsword.com/bvandeusen/ci-go:1.26
|
||||||
git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
git.fabledsword.com/bvandeusen/ci-android:36
|
||||||
```
|
```
|
||||||
|
|
||||||
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`.gitea/workflows/release.yml`).
|
- `ci-go:1.26` — Go server tests (`.gitea/workflows/test-go.yml`), web SPA tests (`.gitea/workflows/test-web.yml`), and the release container build (`release.yml`'s `image-release` job).
|
||||||
- `ci-flutter:3.44` — Flutter client tests + debug/release APK builds (`.gitea/workflows/flutter.yml`).
|
- `ci-android:36` — native Kotlin/Compose client: ktlint + detekt + unit tests + debug APK (`.gitea/workflows/android.yml`), and the signed release APK (`release.yml`'s `android-release` job).
|
||||||
|
|
||||||
|
**`ci-flutter` is no longer consumed.** The M8 rewrite replaced the Flutter
|
||||||
|
client with the native Android app and `flutter.yml` was removed; `ci-android`
|
||||||
|
took its place. `flutter_client/` is still in the tree but nothing builds it.
|
||||||
|
CI-Runner still publishes `ci-flutter` and will retire it once that directory
|
||||||
|
goes — so if the Flutter client is ever revived, say so there first.
|
||||||
|
|
||||||
## Image deps used
|
## Image deps used
|
||||||
|
|
||||||
@@ -25,13 +31,20 @@ git.fabledsword.com/bvandeusen/ci-flutter:3.44
|
|||||||
- **docker buildx** — release container build + push in `release.yml`.
|
- **docker buildx** — release container build + push in `release.yml`.
|
||||||
- **curl** — release-asset polling / upload in `release.yml`.
|
- **curl** — release-asset polling / upload in `release.yml`.
|
||||||
|
|
||||||
### From `ci-flutter:3.44`
|
### From `ci-android:36`
|
||||||
- **Flutter** (3.44 stable channel) — `flutter pub get`, `flutter analyze --fatal-infos`, `flutter test`, `flutter build apk` (debug + signed release).
|
- **JDK 25** — Gradle launcher + Android build. Requires Gradle 9.1.0+ in
|
||||||
- **Dart** — `dart run tool/gen_tokens.dart`, `dart run build_runner build` (drift codegen).
|
`android/gradle/wrapper`; older Gradle rejects JDK 25 with an opaque `"25.0.3"`
|
||||||
- **Android SDK + NDK + cmdline-tools + build-tools** — APK assembly + signing.
|
error. The workflows also set `JAVA_TOOL_OPTIONS=--enable-native-access=ALL-UNNAMED`
|
||||||
- **Java 25** — Gradle / Android build.
|
to silence Gradle's launcher-JVM restricted-method warning.
|
||||||
|
- **Android SDK + cmdline-tools + build-tools 36.0.0** — APK assembly + signing.
|
||||||
|
No NDK: the native client has no C/C++ sources (this is why it isn't on
|
||||||
|
`ci-flutter`).
|
||||||
|
- **ktlint + detekt** — `./gradlew ktlintCheck` and `./gradlew detekt` in
|
||||||
|
`android.yml`. Image pins track `android/gradle/libs.versions.toml` so local
|
||||||
|
and CI checks agree.
|
||||||
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
- **git** — `actions/checkout@v4` baseline (and any shell git operations).
|
||||||
- **base64 + curl** — keystore decode + release-asset upload in the tag-build path.
|
- **base64 + curl** — keystore decode + release-asset upload in `release.yml`'s
|
||||||
|
`android-release` job.
|
||||||
|
|
||||||
## Per-job tool installs
|
## Per-job tool installs
|
||||||
|
|
||||||
@@ -39,9 +52,69 @@ None.
|
|||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions.
|
- **Label/image split.** Workflows keep `runs-on: go-ci` / `runs-on: flutter-ci` as the scheduling label per the [`ci-runners.md`](https://…/FabledRulebook/ci-runners.md) "label = scheduling handle, image = `container.image`" pattern. The labels are intentional handles, not toolchain assertions — which is why the Android jobs still schedule on `flutter-ci` while pulling `ci-android:36`. Switch them to `android-ci` if that runner label is ever registered; nothing breaks either way.
|
||||||
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
- **Integration-job docker-socket dependency.** `test-go.yml`'s integration job uses the runner's shared docker socket (`/var/run/docker.sock`) to bridge-IP-discover the per-job Postgres service container by name + network intersection — the dev compose's `minstrel-postgres-*` containers are explicitly skipped as belt-and-suspenders. Depends on `act_runner.valid_volumes` whitelisting the socket; if that ever stops auto-mounting, integration tests fail at the `docker inspect` step.
|
||||||
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
- **Go toolchain pin.** `go.mod` is on `go 1.25.0` because `golang.org/x/crypto v0.51.0` declares 1.25 as its minimum. `ci-go:1.26` satisfies this with headroom. Future `x/crypto` bumps that move the Go floor should be paired with an image-tag bump in this file + the workflows.
|
||||||
- **In-app update channel polling.** `release.yml` polls Gitea's release-asset API for up to 15 min on tag pushes to fetch the APK that `flutter.yml` is concurrently attaching to the same release. The asset eventually appears because `flutter.yml` and `release.yml` run in parallel on the same tag; if the polling times out, the server image ships without the bundled update channel (graceful degradation, not a build failure).
|
- **In-app update channel — `needs:`, not polling.** `release.yml`'s `image-release` job declares `needs: [android-release]`, so on tag pushes the signed APK is guaranteed present before the image build starts — no polling window, no race. (The old cross-workflow polling against `flutter.yml` is gone with that workflow.) On non-tag `main` pushes `android-release` is skipped and `image-release` instead pulls the most recent release's APK and reconstructs its exact `versionName`, so `:latest` never ships without an update channel. It degrades to an empty `client/` — never a wrong version — if no release, asset, or tag commit-count can be resolved.
|
||||||
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
- **Cache server reachability.** `test-web.yml` does NOT use `cache: 'npm'` on `actions/setup-node` — the Gitea Actions cache server isn't reachable from this runner's container network and `setup-node` was burning ~4m41s on ETIMEDOUT before failing open. With the migration to `ci-go:1.26`, `setup-node` is removed entirely (Node is in the image). The cache concern reappears if a future change re-introduces a network-dependent action.
|
||||||
|
- **Artifacts — use the mirrored actions, never `actions/{upload,download}-artifact`.**
|
||||||
|
```yaml
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/upload-artifact@cb8afe72b42edc798abfb8fcb556cf660d894245
|
||||||
|
uses: https://git.fabledsword.com/bvandeusen/download-artifact@8d4e9521a5f7e5f8b6351f341f719f9f45a92a3a
|
||||||
|
```
|
||||||
|
Upstream's `@v4+` cannot work against this instance and no server-side change
|
||||||
|
will help: `isGhes()` rejects any hostname that isn't `github.com` /
|
||||||
|
`*.ghe.com` / `*.localhost` and throws before it opens a connection, so the
|
||||||
|
server is never asked what it supports. `@v3` is worse — it reports success,
|
||||||
|
and Gitea then serves artifacts back only through the v4 API
|
||||||
|
(`content_encoding = application/zip`), so a v3 upload is stored but invisible
|
||||||
|
to every retrieval path. A green job producing nothing retrievable; that is how
|
||||||
|
72 unreachable artifacts accumulated on this repo. Scribe issues 2255 / 2270.
|
||||||
|
|
||||||
|
Both are pull mirrors of the Forgejo project's forks
|
||||||
|
(`code.forgejo.org/forgejo/{upload,download}-artifact`, one commit on upstream
|
||||||
|
disabling that check), mirrored so CI depends on commits we hold and pinned by
|
||||||
|
SHA because the mirrors auto-sync every 8h — a moved upstream tag would
|
||||||
|
otherwise silently change what runs.
|
||||||
|
|
||||||
|
**Match the pins on `@actions/artifact`, not on the actions' own version
|
||||||
|
numbers.** The two actions release on unrelated cadences, so equal version
|
||||||
|
numbers do NOT mean a compatible pair — upload `v5` bundles `@actions/artifact`
|
||||||
|
^4.0.0 while download `v5` bundles ^2.3.2. The pins above are upload **v5** and
|
||||||
|
download **v6**, which is the pairing that puts ^4.0.0 on both sides. This
|
||||||
|
matters because `release.yml` is a producer/consumer pair — `android-release`
|
||||||
|
uploads `minstrel-apk`, `image-release` downloads it — and a protocol mismatch
|
||||||
|
across it yields an empty listing rather than an error, exactly the silent
|
||||||
|
failure this entry exists to prevent.
|
||||||
|
|
||||||
|
| tag | `@actions/artifact` | runtime |
|
||||||
|
|---|---|---|
|
||||||
|
| upload v4 | ^2.1.1 | node20 |
|
||||||
|
| **upload v5** ← pinned | **^4.0.0** | node20 |
|
||||||
|
| download v4 | ^2.1.1 | node20 |
|
||||||
|
| download v5 | ^2.3.2 | node20 |
|
||||||
|
| **download v6** ← pinned | **^4.0.0** | node20 |
|
||||||
|
| download v7 | ^5.0.0 | **node24** |
|
||||||
|
|
||||||
|
The only true protocol break in this history was **v3 → v4** (upstream:
|
||||||
|
"Downloading artifacts that were created from `actions/upload-artifact@v3` and
|
||||||
|
below are not supported"); v4-and-up are one family. Later majors are mostly
|
||||||
|
ergonomics and runtime — upload v4 forbids re-uploading a name and caps a job
|
||||||
|
at 500 artifacts; download v5 made by-ID extraction match by-name.
|
||||||
|
|
||||||
|
**Do not jump the download pin to v7.** That major is a runner requirement, not
|
||||||
|
a feature change: it moves to `runs.using: node24` and upstream states it
|
||||||
|
"requires a minimum Actions Runner version of 2.327.1 … if you are using
|
||||||
|
self-hosted runners, ensure they are updated before upgrading." act_runner is
|
||||||
|
not GitHub's runner and makes no such version claim, so node24 is unverified
|
||||||
|
here. Everything currently pinned is node20.
|
||||||
|
|
||||||
|
Upload steps set `if-no-files-found: error` rather than the default `warn`, so
|
||||||
|
an upload that matches nothing fails its own job instead of failing the
|
||||||
|
consumer later.
|
||||||
|
|
||||||
|
Retrieval: `GET /api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts`
|
||||||
|
for the id (global run id, not the repo-scoped run number), then
|
||||||
|
`…/actions/artifacts/{id}/zip`. The workstation has no `unzip` — use
|
||||||
|
`python3 -m zipfile -e`.
|
||||||
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
|
- **Friction asks.** None pending. The two images cover everything Minstrel needs.
|
||||||
|
|||||||
@@ -67,15 +67,30 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// discoverTuningResp is the Discover scope on the wire (#2377).
|
||||||
|
type discoverTuningResp struct {
|
||||||
|
TagOverlapWeight float64 `json:"tag_overlap_weight"`
|
||||||
|
SnoozeDays float64 `json:"snooze_days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func discoverRespFrom(d recsettings.DiscoverTuning) discoverTuningResp {
|
||||||
|
return discoverTuningResp{
|
||||||
|
TagOverlapWeight: d.TagOverlapWeight,
|
||||||
|
SnoozeDays: d.SnoozeDays,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// tuningSnapshot is both the GET response and the post-mutation echo:
|
// tuningSnapshot is both the GET response and the post-mutation echo:
|
||||||
// current values alongside shipped defaults so the card can mark
|
// current values alongside shipped defaults so the card can mark
|
||||||
// which knobs deviate.
|
// which knobs deviate.
|
||||||
type tuningSnapshot struct {
|
type tuningSnapshot struct {
|
||||||
Profiles map[string]weightsResp `json:"profiles"`
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
Taste tasteTuningResp `json:"taste"`
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Discover discoverTuningResp `json:"discover"`
|
||||||
Shipped struct {
|
Shipped struct {
|
||||||
Profiles map[string]weightsResp `json:"profiles"`
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
Taste tasteTuningResp `json:"taste"`
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Discover discoverTuningResp `json:"discover"`
|
||||||
} `json:"shipped"`
|
} `json:"shipped"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,11 +101,13 @@ func (h *handlers) tuningSnapshot() tuningSnapshot {
|
|||||||
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||||
}
|
}
|
||||||
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||||
|
out.Discover = discoverRespFrom(h.recSettings.Discover())
|
||||||
out.Shipped.Profiles = map[string]weightsResp{
|
out.Shipped.Profiles = map[string]weightsResp{
|
||||||
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||||
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||||
}
|
}
|
||||||
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||||
|
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +137,12 @@ func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *htt
|
|||||||
}
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if scope == recsettings.ScopeTaste {
|
switch scope {
|
||||||
|
case recsettings.ScopeTaste:
|
||||||
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
||||||
} else {
|
case recsettings.ScopeDiscover:
|
||||||
|
err = h.recSettings.UpdateDiscover(r.Context(), body.Values)
|
||||||
|
default:
|
||||||
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
|
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -104,6 +104,11 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Get("/search", h.handleSearch)
|
authed.Get("/search", h.handleSearch)
|
||||||
authed.Get("/radio", h.handleRadio)
|
authed.Get("/radio", h.handleRadio)
|
||||||
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
authed.Get("/discover/suggestions", h.handleListSuggestions)
|
||||||
|
// Snooze = "not right now", time-boxed and self-expiring
|
||||||
|
// (#2374). Not a dislike — see the migration for why.
|
||||||
|
authed.Post("/discover/suggestions/{mbid}/snooze", h.handleSnoozeSuggestion)
|
||||||
|
authed.Delete("/discover/suggestions/{mbid}/snooze", h.handleUnsnoozeSuggestion)
|
||||||
|
authed.Get("/discover/snoozes", h.handleListSuggestionSnoozes)
|
||||||
authed.Get("/home", h.handleGetHome)
|
authed.Get("/home", h.handleGetHome)
|
||||||
authed.Get("/home/index", h.handleGetHomeIndex)
|
authed.Get("/home/index", h.handleGetHomeIndex)
|
||||||
authed.Post("/events", h.handleEvents)
|
authed.Post("/events", h.handleEvents)
|
||||||
|
|||||||
+159
-1
@@ -2,13 +2,19 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
)
|
)
|
||||||
@@ -19,6 +25,12 @@ type suggestionView struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Score float64 `json:"score"`
|
Score float64 `json:"score"`
|
||||||
Attribution []seedContributionView `json:"attribution"`
|
Attribution []seedContributionView `json:"attribution"`
|
||||||
|
// MatchedTags are the candidate's tags that overlap the user's taste
|
||||||
|
// profile, strongest first (#2377) — the "matches: shoegaze, melancholic"
|
||||||
|
// line. Omitted when empty, which is common: tag coverage for
|
||||||
|
// out-of-library artists is permanently partial (#2376), and the card
|
||||||
|
// falls back to the seed attribution it has always shown.
|
||||||
|
MatchedTags []string `json:"matched_tags,omitempty"`
|
||||||
// ImageURL is resolved on-demand from Lidarr (out-of-library
|
// ImageURL is resolved on-demand from Lidarr (out-of-library
|
||||||
// artists have no local art row). Omitted when Lidarr is disabled
|
// artists have no local art row). Omitted when Lidarr is disabled
|
||||||
// or has no match — the client falls back to a placeholder. Not
|
// or has no match — the client falls back to a placeholder. Not
|
||||||
@@ -65,7 +77,11 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
|
|||||||
halfLife = f
|
halfLife = f
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit)
|
// Read the tuned weight per request so an admin change takes effect on the
|
||||||
|
// next refresh, no restart (rule #25).
|
||||||
|
tagWeight := h.recSettings.Discover().TagOverlapWeight
|
||||||
|
suggestions, err := recommendation.SuggestArtists(
|
||||||
|
r.Context(), h.pool, user.ID, halfLife, limit, tagWeight)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("api: list suggestions", "err", err)
|
h.logger.Error("api: list suggestions", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
|
writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
|
||||||
@@ -86,12 +102,154 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
out = append(out, suggestionView{
|
out = append(out, suggestionView{
|
||||||
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
|
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
|
||||||
|
MatchedTags: s.MatchedTags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
h.resolveSuggestionArt(r.Context(), out)
|
h.resolveSuggestionArt(r.Context(), out)
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxSnoozeDays caps a client-supplied duration. The DEFAULT is not here: it's
|
||||||
|
// a DB-backed knob on the admin tuning card (rule #25), read per request via
|
||||||
|
// recSettings.Discover().SnoozeDays. See #2377.
|
||||||
|
const maxSnoozeDays = 365.0
|
||||||
|
|
||||||
|
// snoozeRequest is the POST body. Both fields are optional in the JSON sense
|
||||||
|
// (an absent body snoozes for the default), but Name is required in practice:
|
||||||
|
// candidates are out-of-library, so the server has no artists row to resolve a
|
||||||
|
// display name from and the un-snooze list would have nothing to show. The
|
||||||
|
// client always has it — it just rendered the card.
|
||||||
|
type snoozeRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Days float64 `json:"days"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// snoozeView is one row of GET /api/discover/snoozes.
|
||||||
|
type snoozeView struct {
|
||||||
|
MBID string `json:"mbid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
SnoozedUntil pgtype.Timestamptz `json:"snoozed_until"`
|
||||||
|
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSnoozeSuggestion implements
|
||||||
|
// POST /api/discover/suggestions/{mbid}/snooze.
|
||||||
|
//
|
||||||
|
// Parks a candidate for `days` (default 90, capped at 365). Idempotent:
|
||||||
|
// snoozing an already-snoozed candidate extends it rather than conflicting.
|
||||||
|
//
|
||||||
|
// This is NOT negative feedback. It records no verdict on the artist and is
|
||||||
|
// never read by internal/taste — see 0049_suggestion_snoozes.up.sql for the
|
||||||
|
// rule #101 reasoning. Returns 204.
|
||||||
|
func (h *handlers) handleSnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
|
||||||
|
if mbid == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty body is a valid "snooze this for the default period", so EOF
|
||||||
|
// is not an error here — decodeBody would reject it as a malformed body.
|
||||||
|
var body snoozeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", ""))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(body.Name)
|
||||||
|
if name == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_body", "name is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days := body.Days
|
||||||
|
if days <= 0 {
|
||||||
|
days = h.recSettings.Discover().SnoozeDays
|
||||||
|
}
|
||||||
|
if days > maxSnoozeDays {
|
||||||
|
// Clamp rather than reject: a client asking for longer than we allow
|
||||||
|
// still means "park this", and failing the write would leave the card
|
||||||
|
// sitting there as if the tap did nothing.
|
||||||
|
days = maxSnoozeDays
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
if err := q.SnoozeSuggestion(r.Context(), dbq.SnoozeSuggestionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CandidateMbid: mbid,
|
||||||
|
CandidateName: name,
|
||||||
|
Column4: days,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("api: snooze suggestion", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("failed to snooze suggestion", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUnsnoozeSuggestion implements
|
||||||
|
// DELETE /api/discover/suggestions/{mbid}/snooze.
|
||||||
|
//
|
||||||
|
// Brings a parked candidate back immediately. 404s an MBID this user never
|
||||||
|
// snoozed, so the client can tell "undone" from "there was nothing there".
|
||||||
|
func (h *handlers) handleUnsnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
|
||||||
|
if mbid == "" {
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.UnsnoozeSuggestion(r.Context(), dbq.UnsnoozeSuggestionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CandidateMbid: mbid,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: unsnooze suggestion", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("failed to unsnooze suggestion", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
writeErr(w, apierror.NotFound("snooze"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListSuggestionSnoozes implements GET /api/discover/snoozes.
|
||||||
|
//
|
||||||
|
// The un-snooze surface needs this: a parked candidate is by definition
|
||||||
|
// absent from the suggestion deck, so without a list there is no way to
|
||||||
|
// reach the DELETE above. Scoped to the caller (rule #47). Expired rows are
|
||||||
|
// already filtered by the query — the hourly gc sweep only reclaims space.
|
||||||
|
func (h *handlers) handleListSuggestionSnoozes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := dbq.New(h.pool).ListActiveSuggestionSnoozes(r.Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: list suggestion snoozes", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("failed to load snoozes", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]snoozeView, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
out = append(out, snoozeView{
|
||||||
|
MBID: row.CandidateMbid,
|
||||||
|
Name: row.CandidateName,
|
||||||
|
SnoozedUntil: row.SnoozedUntil,
|
||||||
|
CreatedAt: row.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
|
// resolveSuggestionArt fills ImageURL on-demand from Lidarr's artist
|
||||||
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
|
// lookup, matched by MBID (foreignArtistId). Best-effort and cache-free:
|
||||||
// Lidarr is the only source — when it's disabled, unreachable, or has
|
// Lidarr is the only source — when it's disabled, unreachable, or has
|
||||||
|
|||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -518,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
|
||||||
|
|||||||
@@ -1022,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,
|
||||||
@@ -1052,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,
|
||||||
@@ -1082,13 +1124,24 @@ type SuggestArtistsForUserRow struct {
|
|||||||
TopPlayCounts []int64
|
TopPlayCounts []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// M5c: per-user artist suggestions ranked by signal x similarity. The
|
// Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||||
// seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
// through artist_similarity_unmatched (out-of-library candidates only).
|
||||||
// (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
//
|
||||||
// artist_similarity_unmatched and filters out candidates already in
|
// Seeds are TIERED (rule #131) so the surface never empties:
|
||||||
// library or already in a non-terminal lidarr_request. The outer SELECT
|
//
|
||||||
// aggregates per candidate, returning the top-3 contributing seeds for
|
// tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||||
// attribution. $1=user_id, $2=half_life_days, $3=limit.
|
// SIGNED by internal/taste, so an artist the user has drifted away
|
||||||
|
// from stops contributing instead of accumulating forever.
|
||||||
|
// tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||||
|
// (new account, or before the first daily recompute).
|
||||||
|
//
|
||||||
|
// The signal is log-damped: contribution is signal x similarity, and the old
|
||||||
|
// undamped sum let one heavily-played artist's neighbours take every slot --
|
||||||
|
// entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||||
|
//
|
||||||
|
// Candidates already in the library, already requested and not terminal, or
|
||||||
|
// snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
|
||||||
|
// decay), $3=limit.
|
||||||
func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) {
|
func (q *Queries) SuggestArtistsForUser(ctx context.Context, arg SuggestArtistsForUserParams) ([]SuggestArtistsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
|
rows, err := q.db.Query(ctx, suggestArtistsForUser, arg.UserID, arg.Column2, arg.Limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const getDiscoverTuning = `-- name: GetDiscoverTuning :one
|
||||||
|
SELECT singleton, tag_overlap_weight, snooze_days, updated_at FROM discover_tuning WHERE singleton = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetDiscoverTuning(ctx context.Context) (DiscoverTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getDiscoverTuning)
|
||||||
|
var i DiscoverTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.TagOverlapWeight,
|
||||||
|
&i.SnoozeDays,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const getTasteTuning = `-- name: GetTasteTuning :one
|
const getTasteTuning = `-- name: GetTasteTuning :one
|
||||||
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
|
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
|
||||||
`
|
`
|
||||||
@@ -118,6 +134,32 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateDiscoverTuning = `-- name: UpdateDiscoverTuning :one
|
||||||
|
UPDATE discover_tuning
|
||||||
|
SET tag_overlap_weight = $1,
|
||||||
|
snooze_days = $2,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE singleton = true
|
||||||
|
RETURNING singleton, tag_overlap_weight, snooze_days, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateDiscoverTuningParams struct {
|
||||||
|
TagOverlapWeight float64
|
||||||
|
SnoozeDays float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateDiscoverTuning(ctx context.Context, arg UpdateDiscoverTuningParams) (DiscoverTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateDiscoverTuning, arg.TagOverlapWeight, arg.SnoozeDays)
|
||||||
|
var i DiscoverTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.TagOverlapWeight,
|
||||||
|
&i.SnoozeDays,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
const updateTasteTuning = `-- name: UpdateTasteTuning :one
|
const updateTasteTuning = `-- name: UpdateTasteTuning :one
|
||||||
UPDATE taste_tuning
|
UPDATE taste_tuning
|
||||||
SET half_life_days = $1,
|
SET half_life_days = $1,
|
||||||
@@ -226,6 +268,24 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const upsertDiscoverTuningDefaults = `-- name: UpsertDiscoverTuningDefaults :exec
|
||||||
|
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
|
||||||
|
VALUES (true, $1, $2)
|
||||||
|
ON CONFLICT (singleton) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertDiscoverTuningDefaultsParams struct {
|
||||||
|
TagOverlapWeight float64
|
||||||
|
SnoozeDays float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot reconcile for the Discover scope (#2377). Never overwrites
|
||||||
|
// operator-tuned values, same contract as the other two.
|
||||||
|
func (q *Queries) UpsertDiscoverTuningDefaults(ctx context.Context, arg UpsertDiscoverTuningDefaultsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertDiscoverTuningDefaults, arg.TagOverlapWeight, arg.SnoozeDays)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||||
INSERT INTO taste_tuning (
|
INSERT INTO taste_tuning (
|
||||||
singleton, half_life_days, engagement_hard_skip,
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS suggestion_snoozes_expiry_idx;
|
||||||
|
DROP TABLE IF EXISTS suggestion_snoozes;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- 0049_suggestion_snoozes.up.sql — time-boxed "not right now" on a Discover
|
||||||
|
-- artist suggestion (#2374, milestone #268 slice 3).
|
||||||
|
--
|
||||||
|
-- This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down /
|
||||||
|
-- exclusion UI, and a snooze deliberately isn't one: it records no verdict on
|
||||||
|
-- the music, expires on its own, and MUST NEVER feed the taste profile. It is
|
||||||
|
-- acquisition triage — "I don't want to request this right now" — so the same
|
||||||
|
-- candidate is free to return once snoozed_until passes. Anything that reads
|
||||||
|
-- this table as negative preference signal is a bug.
|
||||||
|
--
|
||||||
|
-- Per-user (rule #47), never global: one household member parking a
|
||||||
|
-- suggestion must not remove it from anyone else's deck.
|
||||||
|
--
|
||||||
|
-- candidate_mbid is text with NO foreign key, on purpose. Suggestions come
|
||||||
|
-- from artist_similarity_unmatched and are out-of-library BY DEFINITION, so
|
||||||
|
-- there is no artists row to reference — the MBID is the only stable identity
|
||||||
|
-- available. candidate_name is denormalized for the same reason: the manage /
|
||||||
|
-- un-snooze list has nowhere else to resolve a display name from.
|
||||||
|
|
||||||
|
CREATE TABLE suggestion_snoozes (
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
candidate_mbid text NOT NULL,
|
||||||
|
candidate_name text NOT NULL,
|
||||||
|
snoozed_until timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, candidate_mbid)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Supports the gc sweep's unqualified `WHERE snoozed_until < now()` scan. The
|
||||||
|
-- composite PK already covers every per-user read, so this is the only extra
|
||||||
|
-- index worth its write cost at household-scale row counts (same reasoning as
|
||||||
|
-- lidarr_quarantine in 0011).
|
||||||
|
CREATE INDEX suggestion_snoozes_expiry_idx ON suggestion_snoozes (snoozed_until);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP INDEX IF EXISTS candidate_artist_tag_state_source_idx;
|
||||||
|
DROP TABLE IF EXISTS candidate_artist_tag_state;
|
||||||
|
DROP TABLE IF EXISTS candidate_artist_tags;
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
-- 0050_candidate_artist_tags.up.sql — folksonomy tags for OUT-OF-LIBRARY
|
||||||
|
-- artists (#2376, milestone #268 slice 5).
|
||||||
|
--
|
||||||
|
-- track_tags (0042) cannot hold these: it is FK'd to tracks(id), and a
|
||||||
|
-- Discover candidate has no local row by definition. So this is a parallel
|
||||||
|
-- cache keyed by the candidate's MusicBrainz MBID — the only stable identity
|
||||||
|
-- available for an artist we don't have.
|
||||||
|
--
|
||||||
|
-- Purpose is slice 6: rank suggestions by overlap between these tags and the
|
||||||
|
-- user's taste_profile_tags, turning "neighbour of an artist you play" into
|
||||||
|
-- "matches the sound you like".
|
||||||
|
--
|
||||||
|
-- GLOBAL, not per-user: a candidate's tags are a property of the artist, not
|
||||||
|
-- of anyone's taste. Nothing here is user-scoped, so rule #47 has nothing to
|
||||||
|
-- scope — the per-user part lives entirely in slice 6's ranking.
|
||||||
|
--
|
||||||
|
-- weight is a normalized folksonomy strength in [0,1], same scale as
|
||||||
|
-- track_tags, so the two can be compared without a conversion step.
|
||||||
|
CREATE TABLE candidate_artist_tags (
|
||||||
|
candidate_mbid text NOT NULL,
|
||||||
|
tag text NOT NULL,
|
||||||
|
weight double precision NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (candidate_mbid, tag)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Enrichment bookkeeping. This is a SEPARATE table rather than columns on the
|
||||||
|
-- tags table, because the "providers had nothing" outcome must be recordable
|
||||||
|
-- for a candidate with zero tag rows — otherwise every empty candidate stays
|
||||||
|
-- eligible forever and the worker re-fetches it on every tick.
|
||||||
|
--
|
||||||
|
-- tracks solved the same problem with columns on `tracks` (0042), but there is
|
||||||
|
-- no per-candidate row anywhere to hang them off: artist_similarity_unmatched
|
||||||
|
-- is keyed (seed_artist_id, candidate_mbid, source) and holds MANY rows per
|
||||||
|
-- candidate.
|
||||||
|
--
|
||||||
|
-- Absence of a row here means "never processed", so unlike tracks.tag_source
|
||||||
|
-- this column can be NOT NULL — there is no null-means-pending state to model.
|
||||||
|
-- 'musicbrainz' | 'lastfm' | 'mixed' → found, cached
|
||||||
|
-- 'none' → providers confirmed nothing
|
||||||
|
-- tag_sources_version → bump to re-process settled 'none'
|
||||||
|
--
|
||||||
|
-- A transient failure writes NO row at all (rather than a row it would then
|
||||||
|
-- have to distinguish), which leaves the candidate eligible for the next pass.
|
||||||
|
CREATE TABLE candidate_artist_tag_state (
|
||||||
|
candidate_mbid text PRIMARY KEY,
|
||||||
|
tag_source text NOT NULL,
|
||||||
|
tag_sources_version integer NOT NULL DEFAULT 0,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Serves the eligibility scan's "settled 'none' under an older version"
|
||||||
|
-- branch. The PK already covers the per-candidate lookups.
|
||||||
|
CREATE INDEX candidate_artist_tag_state_source_idx
|
||||||
|
ON candidate_artist_tag_state (tag_source, tag_sources_version);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Drop any audit rows under the scope the constraint is about to forbid,
|
||||||
|
-- otherwise re-adding the narrower CHECK fails against existing data.
|
||||||
|
DELETE FROM recommendation_tuning_audit WHERE scope = 'discover';
|
||||||
|
ALTER TABLE recommendation_tuning_audit
|
||||||
|
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
|
||||||
|
ALTER TABLE recommendation_tuning_audit
|
||||||
|
ADD CONSTRAINT recommendation_tuning_audit_scope_check
|
||||||
|
CHECK (scope IN ('radio', 'daily_mix', 'taste'));
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS discover_tuning;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- 0051_discover_tuning.up.sql — tunable knobs for the Discover request
|
||||||
|
-- surface (#2377, milestone #268 slice 6).
|
||||||
|
--
|
||||||
|
-- A FOURTH tuning scope alongside radio / daily_mix / taste. Its own scope
|
||||||
|
-- rather than extra columns on taste_tuning, for a reason that matters:
|
||||||
|
-- snooze_days lives here, and a snooze must never be read as taste signal
|
||||||
|
-- (#2374). Filing it under 'taste' would put it one careless join away from
|
||||||
|
-- exactly the leak that design forbids.
|
||||||
|
--
|
||||||
|
-- Per rule #25 these are DB-backed and editable in the admin UI with no
|
||||||
|
-- restart — the shipped values below are defaults, not settings.
|
||||||
|
CREATE TABLE discover_tuning (
|
||||||
|
singleton boolean PRIMARY KEY DEFAULT true
|
||||||
|
CONSTRAINT discover_tuning_singleton_check CHECK (singleton),
|
||||||
|
-- How strongly taste-tag overlap boosts a candidate's similarity score.
|
||||||
|
-- The blend is MULTIPLICATIVE: score * (1 + w * overlap), overlap in
|
||||||
|
-- [0,1]. So 0 disables the feature outright and leaves pure similarity
|
||||||
|
-- ranking, 1.0 lets a perfectly-matching candidate double its score, and
|
||||||
|
-- a candidate with no cached tags is unchanged rather than penalised
|
||||||
|
-- (rule #131 — tag coverage is permanently partial, see #2376).
|
||||||
|
tag_overlap_weight double precision NOT NULL,
|
||||||
|
-- Default snooze duration in days. Was a Go constant in
|
||||||
|
-- internal/api/suggestions.go; moved here per rule #25.
|
||||||
|
snooze_days double precision NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
|
||||||
|
VALUES (true, 1.0, 90);
|
||||||
|
|
||||||
|
-- Rule #36: a new value for a CHECK-gated column needs the constraint
|
||||||
|
-- rewritten in the SAME change, or the first audit row written under the new
|
||||||
|
-- scope fails at runtime rather than at migrate time.
|
||||||
|
ALTER TABLE recommendation_tuning_audit
|
||||||
|
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
|
||||||
|
ALTER TABLE recommendation_tuning_audit
|
||||||
|
ADD CONSTRAINT recommendation_tuning_audit_scope_check
|
||||||
|
CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover'));
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
-- Folksonomy tags for out-of-library Discover candidates (#2376). Parallel to
|
||||||
|
-- track_tags.sql, but keyed by MBID because the artist has no local row. See
|
||||||
|
-- 0050_candidate_artist_tags.up.sql for why the bookkeeping is its own table.
|
||||||
|
|
||||||
|
-- name: ListCandidateArtistsMissingTags :many
|
||||||
|
-- Candidates eligible for tag enrichment: never processed (no state row) or
|
||||||
|
-- settled 'none' under an older provider version.
|
||||||
|
--
|
||||||
|
-- artist_similarity_unmatched holds one row per (seed, candidate, source), so
|
||||||
|
-- this GROUPs to one row per candidate — enriching the same MBID once per seed
|
||||||
|
-- that pointed at it would multiply the API calls for no gain.
|
||||||
|
--
|
||||||
|
-- ORDER BY summed similarity DESC is the load-bearing part. The candidate pool
|
||||||
|
-- is O(library artists x neighbours per artist) — thousands — and MusicBrainz
|
||||||
|
-- allows ~1 req/s, so it can NEVER be fully enriched in one pass. Draining in
|
||||||
|
-- strength order means the candidates most likely to actually reach a user's
|
||||||
|
-- deck get tags first, and the long tail fills in over subsequent ticks
|
||||||
|
-- instead of starving behind it.
|
||||||
|
--
|
||||||
|
-- Already-in-library candidates are skipped: they have an artists row, so
|
||||||
|
-- their tags belong in track_tags, and the suggestion query filters them out
|
||||||
|
-- anyway. $1 = current tag_sources_version, $2 = limit.
|
||||||
|
-- candidate_name is coalesced to the empty string so it lands non-nullable in
|
||||||
|
-- Go: the name is only a Last.fm lookup key, and empty simply means "MBID-keyed
|
||||||
|
-- providers only", which the provider chain already handles. max() is an
|
||||||
|
-- arbitrary-but-deterministic pick when several seeds spell one MBID
|
||||||
|
-- differently.
|
||||||
|
SELECT u.candidate_mbid,
|
||||||
|
coalesce(max(u.candidate_name), '')::text AS candidate_name,
|
||||||
|
sum(u.score)::float8 AS total_score
|
||||||
|
FROM artist_similarity_unmatched u
|
||||||
|
LEFT JOIN candidate_artist_tag_state s ON s.candidate_mbid = u.candidate_mbid
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM artists a WHERE a.mbid = u.candidate_mbid)
|
||||||
|
AND (
|
||||||
|
s.candidate_mbid IS NULL
|
||||||
|
OR (s.tag_source = 'none' AND s.tag_sources_version < $1)
|
||||||
|
)
|
||||||
|
GROUP BY u.candidate_mbid
|
||||||
|
ORDER BY total_score DESC, u.candidate_mbid
|
||||||
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- name: DeleteCandidateArtistTags :exec
|
||||||
|
-- Clear a candidate's cached tags before rewriting (atomic replace by the
|
||||||
|
-- caller, same shape as DeleteTrackTags).
|
||||||
|
DELETE FROM candidate_artist_tags WHERE candidate_mbid = $1;
|
||||||
|
|
||||||
|
-- name: InsertCandidateArtistTag :exec
|
||||||
|
-- Upsert one (candidate, tag); keep the stronger weight when two providers
|
||||||
|
-- agree on a tag with different folksonomy strengths.
|
||||||
|
INSERT INTO candidate_artist_tags (candidate_mbid, tag, weight)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (candidate_mbid, tag)
|
||||||
|
DO UPDATE SET weight = GREATEST(candidate_artist_tags.weight, EXCLUDED.weight);
|
||||||
|
|
||||||
|
-- name: SetCandidateArtistTagState :exec
|
||||||
|
-- Stamp the enrichment outcome so the drainer skips settled candidates.
|
||||||
|
-- $2 = 'musicbrainz' | 'lastfm' | 'mixed' | 'none', $3 = current version.
|
||||||
|
INSERT INTO candidate_artist_tag_state (candidate_mbid, tag_source, tag_sources_version)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (candidate_mbid) DO UPDATE
|
||||||
|
SET tag_source = EXCLUDED.tag_source,
|
||||||
|
tag_sources_version = EXCLUDED.tag_sources_version,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- name: ListCandidateArtistTagsForMbids :many
|
||||||
|
-- Cached tags for a set of candidates, for slice 6's taste-overlap ranking.
|
||||||
|
-- One row per (candidate, tag).
|
||||||
|
SELECT candidate_mbid, tag, weight
|
||||||
|
FROM candidate_artist_tags
|
||||||
|
WHERE candidate_mbid = ANY($1::text[]);
|
||||||
|
|
||||||
|
-- name: CountCandidateArtistTagCoverage :one
|
||||||
|
-- Operator-facing coverage: how many distinct candidates have been processed,
|
||||||
|
-- and how many of those actually yielded tags. The gap is the honest ceiling
|
||||||
|
-- from the task — obscure artists with no MBID presence or no upstream tags
|
||||||
|
-- stay thin no matter how long the worker runs, and that is worth being able
|
||||||
|
-- to see rather than inferring from a silent surface.
|
||||||
|
SELECT count(*)::bigint AS processed,
|
||||||
|
count(*) FILTER (WHERE tag_source <> 'none')::bigint AS with_tags
|
||||||
|
FROM candidate_artist_tag_state;
|
||||||
|
|
||||||
|
-- name: GcDeleteOrphanedCandidateArtistTags :execrows
|
||||||
|
-- Drops cached tags for candidates that no longer appear in the similarity
|
||||||
|
-- feed, or that have since been added to the library (their tags now live in
|
||||||
|
-- track_tags). The feed is refetched periodically and churns, so without this
|
||||||
|
-- the cache only ever grows.
|
||||||
|
DELETE FROM candidate_artist_tags t
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM artist_similarity_unmatched u
|
||||||
|
WHERE u.candidate_mbid = t.candidate_mbid
|
||||||
|
)
|
||||||
|
OR EXISTS (SELECT 1 FROM artists a WHERE a.mbid = t.candidate_mbid);
|
||||||
|
|
||||||
|
-- name: GcDeleteOrphanedCandidateArtistTagState :execrows
|
||||||
|
-- Same sweep for the bookkeeping rows. Kept as a separate statement rather
|
||||||
|
-- than a cascade: the two tables are independent by design (a 'none' outcome
|
||||||
|
-- has state but no tags), so neither can be the parent of the other.
|
||||||
|
DELETE FROM candidate_artist_tag_state s
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM artist_similarity_unmatched u
|
||||||
|
WHERE u.candidate_mbid = s.candidate_mbid
|
||||||
|
)
|
||||||
|
OR EXISTS (SELECT 1 FROM artists a WHERE a.mbid = s.candidate_mbid);
|
||||||
@@ -258,27 +258,67 @@ ORDER BY started_at DESC
|
|||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: SuggestArtistsForUser :many
|
-- name: SuggestArtistsForUser :many
|
||||||
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
-- Per-user artist suggestions ranked by taste signal x similarity, projected
|
||||||
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays
|
-- through artist_similarity_unmatched (out-of-library candidates only).
|
||||||
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against
|
--
|
||||||
-- artist_similarity_unmatched and filters out candidates already in
|
-- Seeds are TIERED (rule #131) so the surface never empties:
|
||||||
-- library or already in a non-terminal lidarr_request. The outer SELECT
|
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
|
||||||
-- aggregates per candidate, returning the top-3 contributing seeds for
|
-- SIGNED by internal/taste, so an artist the user has drifted away
|
||||||
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
|
-- from stops contributing instead of accumulating forever.
|
||||||
WITH seeds AS (
|
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
|
||||||
|
-- (new account, or before the first daily recompute).
|
||||||
|
--
|
||||||
|
-- The signal is log-damped: contribution is signal x similarity, and the old
|
||||||
|
-- undamped sum let one heavily-played artist's neighbours take every slot --
|
||||||
|
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
|
||||||
|
--
|
||||||
|
-- Candidates already in the library, already requested and not terminal, or
|
||||||
|
-- snoozed by this user, are excluded. $1=user_id, $2=half_life_days (tier 2
|
||||||
|
-- decay), $3=limit.
|
||||||
|
WITH artist_plays AS (
|
||||||
|
-- Completed plays only. The previous seed query counted every play_event,
|
||||||
|
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
|
||||||
|
-- its neighbours at the user (issue #2367 mechanism 3).
|
||||||
|
SELECT t.artist_id, count(*)::bigint AS play_count
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
profile_seeds AS (
|
||||||
|
SELECT tpa.artist_id, tpa.weight AS raw_signal
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
WHERE tpa.user_id = $1 AND tpa.weight > 0
|
||||||
|
),
|
||||||
|
fallback_seeds AS (
|
||||||
SELECT a.id AS artist_id,
|
SELECT a.id AS artist_id,
|
||||||
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
|
||||||
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
|
||||||
AS signal,
|
AS raw_signal
|
||||||
(gla.artist_id IS NOT NULL) AS is_liked,
|
|
||||||
COUNT(pe.id)::bigint AS play_count
|
|
||||||
FROM artists a
|
FROM artists a
|
||||||
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
|
||||||
LEFT JOIN tracks t ON t.artist_id = a.id
|
LEFT JOIN tracks t ON t.artist_id = a.id
|
||||||
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
|
LEFT JOIN play_events pe
|
||||||
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
|
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
|
||||||
|
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
|
||||||
GROUP BY a.id, gla.artist_id
|
GROUP BY a.id, gla.artist_id
|
||||||
),
|
),
|
||||||
|
seeds AS (
|
||||||
|
SELECT s.artist_id,
|
||||||
|
ln(1.0 + s.raw_signal) AS signal,
|
||||||
|
(gla.artist_id IS NOT NULL) AS is_liked,
|
||||||
|
COALESCE(ap.play_count, 0)::bigint AS play_count
|
||||||
|
FROM (
|
||||||
|
SELECT artist_id, raw_signal FROM profile_seeds
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, raw_signal FROM fallback_seeds
|
||||||
|
) s
|
||||||
|
LEFT JOIN general_likes_artists gla
|
||||||
|
ON gla.artist_id = s.artist_id AND gla.user_id = $1
|
||||||
|
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
|
||||||
|
WHERE s.raw_signal > 0
|
||||||
|
),
|
||||||
contributions AS (
|
contributions AS (
|
||||||
SELECT u.candidate_mbid,
|
SELECT u.candidate_mbid,
|
||||||
u.candidate_name,
|
u.candidate_name,
|
||||||
@@ -295,6 +335,18 @@ contributions AS (
|
|||||||
AND r.lidarr_artist_mbid = u.candidate_mbid
|
AND r.lidarr_artist_mbid = u.candidate_mbid
|
||||||
AND r.status NOT IN ('rejected', 'failed')
|
AND r.status NOT IN ('rejected', 'failed')
|
||||||
)
|
)
|
||||||
|
-- Snoozed by this user and not yet expired (#2374). Time-boxed and
|
||||||
|
-- per-user: the candidate returns on its own once snoozed_until
|
||||||
|
-- passes, and stays visible to everyone else meanwhile. Deliberately
|
||||||
|
-- filtered at the candidate stage, NOT folded into the score — a
|
||||||
|
-- snooze carries no opinion about the music, so it must not become a
|
||||||
|
-- ranking signal.
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM suggestion_snoozes s
|
||||||
|
WHERE s.user_id = $1
|
||||||
|
AND s.candidate_mbid = u.candidate_mbid
|
||||||
|
AND s.snoozed_until > now()
|
||||||
|
)
|
||||||
)
|
)
|
||||||
SELECT candidate_mbid,
|
SELECT candidate_mbid,
|
||||||
candidate_name,
|
candidate_name,
|
||||||
|
|||||||
@@ -53,6 +53,24 @@ UPDATE taste_tuning
|
|||||||
WHERE singleton = true
|
WHERE singleton = true
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpsertDiscoverTuningDefaults :exec
|
||||||
|
-- Boot reconcile for the Discover scope (#2377). Never overwrites
|
||||||
|
-- operator-tuned values, same contract as the other two.
|
||||||
|
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
|
||||||
|
VALUES (true, $1, $2)
|
||||||
|
ON CONFLICT (singleton) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: GetDiscoverTuning :one
|
||||||
|
SELECT * FROM discover_tuning WHERE singleton = true;
|
||||||
|
|
||||||
|
-- name: UpdateDiscoverTuning :one
|
||||||
|
UPDATE discover_tuning
|
||||||
|
SET tag_overlap_weight = $1,
|
||||||
|
snooze_days = $2,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE singleton = true
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: InsertTuningAudit :exec
|
-- name: InsertTuningAudit :exec
|
||||||
-- changes is a jsonb array of {field, old, new} objects.
|
-- changes is a jsonb array of {field, old, new} objects.
|
||||||
INSERT INTO recommendation_tuning_audit (scope, action, changes)
|
INSERT INTO recommendation_tuning_audit (scope, action, changes)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- Time-boxed "not right now" on a Discover artist suggestion (#2374).
|
||||||
|
--
|
||||||
|
-- See 0049_suggestion_snoozes.up.sql for why this is a snooze and not a
|
||||||
|
-- dismissal: it records no verdict on the music, expires on its own, and
|
||||||
|
-- must never reach the taste profile. Nothing in internal/taste may read
|
||||||
|
-- this table.
|
||||||
|
|
||||||
|
-- name: SnoozeSuggestion :exec
|
||||||
|
-- Upsert, so re-snoozing an already-snoozed candidate EXTENDS it instead of
|
||||||
|
-- erroring on the PK. The name is refreshed too — a later suggestion may
|
||||||
|
-- carry a corrected spelling from the similarity feed.
|
||||||
|
-- $1=user_id, $2=candidate_mbid, $3=candidate_name, $4=duration in days.
|
||||||
|
INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
|
||||||
|
VALUES ($1, $2, $3, now() + ($4::float8 * INTERVAL '1 day'))
|
||||||
|
ON CONFLICT (user_id, candidate_mbid) DO UPDATE
|
||||||
|
SET snoozed_until = EXCLUDED.snoozed_until,
|
||||||
|
candidate_name = EXCLUDED.candidate_name;
|
||||||
|
|
||||||
|
-- name: UnsnoozeSuggestion :execrows
|
||||||
|
-- Row count is returned so the handler can 404 an MBID that was never
|
||||||
|
-- snoozed rather than reporting success for a no-op.
|
||||||
|
DELETE FROM suggestion_snoozes
|
||||||
|
WHERE user_id = $1 AND candidate_mbid = $2;
|
||||||
|
|
||||||
|
-- name: ListActiveSuggestionSnoozes :many
|
||||||
|
-- Backs the manage / un-snooze surface. Expired rows are filtered HERE
|
||||||
|
-- rather than left to the sweeper: gc runs on an hourly tick, so a row can
|
||||||
|
-- outlive its expiry by up to a tick and must not read as still-snoozed in
|
||||||
|
-- the meantime.
|
||||||
|
SELECT candidate_mbid, candidate_name, snoozed_until, created_at
|
||||||
|
FROM suggestion_snoozes
|
||||||
|
WHERE user_id = $1 AND snoozed_until > now()
|
||||||
|
ORDER BY snoozed_until, candidate_mbid;
|
||||||
|
|
||||||
|
-- name: GcDeleteExpiredSuggestionSnoozes :execrows
|
||||||
|
-- Keeps the table from growing without bound. Every read already filters on
|
||||||
|
-- snoozed_until > now(), so deleting an expired row changes no behaviour —
|
||||||
|
-- this is purely reclamation.
|
||||||
|
DELETE FROM suggestion_snoozes WHERE snoozed_until < now();
|
||||||
@@ -52,6 +52,18 @@ var dataTables = []string{
|
|||||||
"sessions",
|
"sessions",
|
||||||
"lidarr_quarantine_actions",
|
"lidarr_quarantine_actions",
|
||||||
"lidarr_quarantine",
|
"lidarr_quarantine",
|
||||||
|
// #2374. Keyed by (user_id, candidate_mbid) with no FK to artists —
|
||||||
|
// candidates are out-of-library — so the CASCADE from artists/users
|
||||||
|
// does NOT reach it for a leftover row whose user survived. Truncate
|
||||||
|
// explicitly or a stale snooze silently hides a candidate from the
|
||||||
|
// next test's suggestion assertions.
|
||||||
|
"suggestion_snoozes",
|
||||||
|
// #2376. Same reasoning: keyed by candidate MBID with no FK anywhere,
|
||||||
|
// so nothing cascades to them. A leftover tag row would make a
|
||||||
|
// candidate look enriched to the next test, and a leftover state row
|
||||||
|
// would make it look already-settled and thus ineligible.
|
||||||
|
"candidate_artist_tags",
|
||||||
|
"candidate_artist_tag_state",
|
||||||
"playlist_tracks",
|
"playlist_tracks",
|
||||||
"playlists",
|
"playlists",
|
||||||
"library_changes", // M7 #357 — must reset to keep cursor isolated per test
|
"library_changes", // M7 #357 — must reset to keep cursor isolated per test
|
||||||
@@ -70,6 +82,10 @@ var dataTables = []string{
|
|||||||
// (#1250), so truncating gives each test pristine tuning values.
|
// (#1250), so truncating gives each test pristine tuning values.
|
||||||
"recommendation_weight_profiles",
|
"recommendation_weight_profiles",
|
||||||
"taste_tuning",
|
"taste_tuning",
|
||||||
|
// #2377. Same reasoning as taste_tuning above: recsettings.New re-seeds
|
||||||
|
// shipped defaults on every construction, so truncating gives each test
|
||||||
|
// pristine Discover knobs rather than whatever a previous test tuned.
|
||||||
|
"discover_tuning",
|
||||||
"recommendation_tuning_audit",
|
"recommendation_tuning_audit",
|
||||||
"tracks",
|
"tracks",
|
||||||
"albums",
|
"albums",
|
||||||
|
|||||||
@@ -17,6 +17,9 @@
|
|||||||
// - GcResetStuckSystemPlaylistRuns (#574)
|
// - GcResetStuckSystemPlaylistRuns (#574)
|
||||||
// - GcDeleteExpiredPasswordResets (#575)
|
// - GcDeleteExpiredPasswordResets (#575)
|
||||||
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
|
// - GcPruneDiagnostics (M9 — diagnostics 30d retention)
|
||||||
|
// - GcDeleteExpiredSuggestionSnoozes (#2374 — snoozes expire, then go)
|
||||||
|
// - GcDeleteOrphanedCandidateArtistTags(+State) (#2376 — the similarity
|
||||||
|
// feed churns, so cached candidate tags outlive their candidates)
|
||||||
package gc
|
package gc
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -84,6 +87,14 @@ func (w *Worker) tickOnce(ctx context.Context) {
|
|||||||
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
w.runSweep(ctx, "reset_stuck_system_runs", q.GcResetStuckSystemPlaylistRuns)
|
||||||
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
w.runSweep(ctx, "delete_expired_password_resets", q.GcDeleteExpiredPasswordResets)
|
||||||
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
|
w.runSweep(ctx, "prune_diagnostics", q.GcPruneDiagnostics)
|
||||||
|
w.runSweep(ctx, "delete_expired_suggestion_snoozes", q.GcDeleteExpiredSuggestionSnoozes)
|
||||||
|
// Tags before state: if the process dies between the two, a candidate left
|
||||||
|
// with a state row and no tags simply reads as "settled, nothing found",
|
||||||
|
// which is already a valid state. The reverse order could leave tags with
|
||||||
|
// no state row, which the drainer would treat as never-processed and
|
||||||
|
// re-fetch on top of rows that are already there.
|
||||||
|
w.runSweep(ctx, "orphaned_candidate_artist_tags", q.GcDeleteOrphanedCandidateArtistTags)
|
||||||
|
w.runSweep(ctx, "orphaned_candidate_artist_tag_state", q.GcDeleteOrphanedCandidateArtistTagState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSweep is a small adapter so each sweep call site is a one-liner
|
// runSweep is a small adapter so each sweep call site is a one-liner
|
||||||
|
|||||||
@@ -0,0 +1,455 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Slice 5 (#2376): the candidate-artist tag cache for out-of-library Discover
|
||||||
|
// candidates. These cover the SQL rather than the provider chain — the
|
||||||
|
// eligibility query is the piece with real risk in it (a GROUP BY over a
|
||||||
|
// many-rows-per-candidate table, a LEFT JOIN to bookkeeping, and two exclusion
|
||||||
|
// branches), and it's consumed by this package's slice-6 ranking.
|
||||||
|
//
|
||||||
|
// Fixtures live here because the harness and seedUnmatched do.
|
||||||
|
|
||||||
|
const (
|
||||||
|
tagVersionCurrent = 2
|
||||||
|
tagVersionOld = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// listEligible is the query under test, at the current provider version.
|
||||||
|
func listEligible(t *testing.T, pool *pgxpool.Pool, limit int32) []dbq.ListCandidateArtistsMissingTagsRow {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := dbq.New(pool).ListCandidateArtistsMissingTags(context.Background(),
|
||||||
|
dbq.ListCandidateArtistsMissingTagsParams{
|
||||||
|
TagSourcesVersion: tagVersionCurrent,
|
||||||
|
Limit: limit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCandidateArtistsMissingTags: %v", err)
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func setState(t *testing.T, pool *pgxpool.Pool, mbid, source string, version int32) {
|
||||||
|
t.Helper()
|
||||||
|
if err := dbq.New(pool).SetCandidateArtistTagState(context.Background(),
|
||||||
|
dbq.SetCandidateArtistTagStateParams{
|
||||||
|
CandidateMbid: mbid, TagSource: source, TagSourcesVersion: version,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SetCandidateArtistTagState: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mbidsOf(rows []dbq.ListCandidateArtistsMissingTagsRow) []string {
|
||||||
|
out := make([]string, 0, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out = append(out, r.CandidateMbid)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateTags_NeverProcessedIsEligible(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
likeArtist(t, pool, user.ID, seed.ID)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
|
||||||
|
|
||||||
|
rows := listEligible(t, pool, 10)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1: %v", len(rows), mbidsOf(rows))
|
||||||
|
}
|
||||||
|
if rows[0].CandidateName != "Candidate One" {
|
||||||
|
t.Errorf("name = %q, want Candidate One", rows[0].CandidateName)
|
||||||
|
}
|
||||||
|
if rows[0].TotalScore != 0.9 {
|
||||||
|
t.Errorf("score = %v, want 0.9", rows[0].TotalScore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An in-library candidate's tags belong in track_tags, and the suggestion query
|
||||||
|
// filters it out anyway — enriching it would be wasted API budget.
|
||||||
|
func TestCandidateTags_InLibraryCandidateIsExcluded(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
seedArtist(t, pool, "Already Here", "cand-in-lib")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "cand-in-lib", "Already Here", 0.9)
|
||||||
|
|
||||||
|
if rows := listEligible(t, pool, 10); len(rows) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0: %v", len(rows), mbidsOf(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateTags_SettledWithTagsIsExcluded(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
|
||||||
|
setState(t, pool, "cand-1", "musicbrainz", tagVersionCurrent)
|
||||||
|
|
||||||
|
if rows := listEligible(t, pool, 10); len(rows) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0 (already enriched): %v", len(rows), mbidsOf(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A candidate that settled 'none' becomes eligible again when the provider set
|
||||||
|
// widens (version bump) — that's the whole point of the version column. It must
|
||||||
|
// NOT be eligible at the current version, or the worker re-fetches it forever.
|
||||||
|
func TestCandidateTags_SettledNoneReopensOnlyOnVersionBump(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "cand-1", "Candidate One", 0.9)
|
||||||
|
|
||||||
|
setState(t, pool, "cand-1", "none", tagVersionCurrent)
|
||||||
|
if rows := listEligible(t, pool, 10); len(rows) != 0 {
|
||||||
|
t.Errorf("current version: len = %d, want 0 (settled)", len(rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(t, pool, "cand-1", "none", tagVersionOld)
|
||||||
|
if rows := listEligible(t, pool, 10); len(rows) != 1 {
|
||||||
|
t.Errorf("older version: len = %d, want 1 (eligible again)", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// artist_similarity_unmatched holds one row per (seed, candidate, source).
|
||||||
|
// Without the GROUP BY, a candidate that five seeds point at would be fetched
|
||||||
|
// five times — five times the MusicBrainz budget for identical data.
|
||||||
|
func TestCandidateTags_ManySeedsCollapseToOneRowAndSumScores(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
seedA := seedArtist(t, pool, "Seed A", "")
|
||||||
|
seedB := seedArtist(t, pool, "Seed B", "")
|
||||||
|
seedUnmatched(t, pool, seedA.ID, "cand-1", "Candidate One", 0.4)
|
||||||
|
seedUnmatched(t, pool, seedB.ID, "cand-1", "Candidate One", 0.3)
|
||||||
|
|
||||||
|
rows := listEligible(t, pool, 10)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (grouped): %v", len(rows), mbidsOf(rows))
|
||||||
|
}
|
||||||
|
if got := rows[0].TotalScore; got < 0.69 || got > 0.71 {
|
||||||
|
t.Errorf("total_score = %v, want ~0.7 (summed across seeds)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pool is far larger than one pass can drain at ~1 req/s, so the ordering
|
||||||
|
// IS the feature: the strongest candidates must be enriched first, or the ones
|
||||||
|
// that actually reach a user's deck starve behind the long tail.
|
||||||
|
func TestCandidateTags_StrongestCandidatesComeFirst(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "weak", "Weak", 0.1)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "strong", "Strong", 0.95)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "middle", "Middle", 0.5)
|
||||||
|
|
||||||
|
rows := listEligible(t, pool, 10)
|
||||||
|
want := []string{"strong", "middle", "weak"}
|
||||||
|
got := mbidsOf(rows)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len = %d, want 3: %v", len(got), got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("order = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the limit takes the strongest, not an arbitrary slice.
|
||||||
|
if top := mbidsOf(listEligible(t, pool, 1)); len(top) != 1 || top[0] != "strong" {
|
||||||
|
t.Errorf("limit 1 returned %v, want [strong]", top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateTags_InsertKeepsTheStrongerWeight(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
ins := func(w float64) {
|
||||||
|
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
|
||||||
|
CandidateMbid: "cand-1", Tag: "shoegaze", Weight: w,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("InsertCandidateArtistTag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ins(0.8)
|
||||||
|
ins(0.3) // weaker second write must not clobber
|
||||||
|
|
||||||
|
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"cand-1"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Weight != 0.8 {
|
||||||
|
t.Errorf("weight = %v, want 0.8 (GREATEST)", rows[0].Weight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateTags_ListForMbidsIgnoresUnaskedCandidates(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, mbid := range []string{"want-1", "want-2", "other"} {
|
||||||
|
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
|
||||||
|
CandidateMbid: mbid, Tag: "rock", Weight: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", mbid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"want-1", "want-2"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Errorf("len = %d, want 2", len(rows))
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.CandidateMbid == "other" {
|
||||||
|
t.Error("returned a candidate that wasn't asked for")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The similarity feed is refetched and churns, so without the sweep the cache
|
||||||
|
// only grows. Both halves must survive/die together for the right candidates.
|
||||||
|
func TestCandidateTags_GcDropsOrphansAndKeepsLiveOnes(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "live", "Live", 0.9)
|
||||||
|
// "gone" is cached but no longer in the feed; "adopted" got added to the
|
||||||
|
// library since, so its tags belong in track_tags now.
|
||||||
|
seedArtist(t, pool, "Adopted", "adopted")
|
||||||
|
seedUnmatched(t, pool, seed.ID, "adopted", "Adopted", 0.8)
|
||||||
|
|
||||||
|
for _, mbid := range []string{"live", "gone", "adopted"} {
|
||||||
|
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
|
||||||
|
CandidateMbid: mbid, Tag: "rock", Weight: 1,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("insert %s: %v", mbid, err)
|
||||||
|
}
|
||||||
|
setState(t, pool, mbid, "musicbrainz", tagVersionCurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := q.GcDeleteOrphanedCandidateArtistTags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GcDeleteOrphanedCandidateArtistTags: %v", err)
|
||||||
|
}
|
||||||
|
if deleted != 2 {
|
||||||
|
t.Errorf("deleted %d tag rows, want 2 (gone + adopted)", deleted)
|
||||||
|
}
|
||||||
|
deletedState, err := q.GcDeleteOrphanedCandidateArtistTagState(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GcDeleteOrphanedCandidateArtistTagState: %v", err)
|
||||||
|
}
|
||||||
|
if deletedState != 2 {
|
||||||
|
t.Errorf("deleted %d state rows, want 2", deletedState)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.ListCandidateArtistTagsForMbids(ctx, []string{"live", "gone", "adopted"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListCandidateArtistTagsForMbids: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 || rows[0].CandidateMbid != "live" {
|
||||||
|
t.Errorf("survivors = %v, want [live] only", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coverage is the operator's window onto the honest ceiling: processed vs
|
||||||
|
// actually-tagged. A big gap means thin upstream data, not a broken worker.
|
||||||
|
func TestCandidateTags_CoverageCountsProcessedAndTagged(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
setState(t, pool, "has-tags", "musicbrainz", tagVersionCurrent)
|
||||||
|
setState(t, pool, "mixed-tags", "mixed", tagVersionCurrent)
|
||||||
|
setState(t, pool, "no-tags", "none", tagVersionCurrent)
|
||||||
|
|
||||||
|
got, err := dbq.New(pool).CountCandidateArtistTagCoverage(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CountCandidateArtistTagCoverage: %v", err)
|
||||||
|
}
|
||||||
|
if got.Processed != 3 {
|
||||||
|
t.Errorf("processed = %d, want 3", got.Processed)
|
||||||
|
}
|
||||||
|
if got.WithTags != 2 {
|
||||||
|
t.Errorf("with_tags = %d, want 2 ('none' excluded)", got.WithTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Slice 6 (#2377): the taste-tag blend, end to end ---
|
||||||
|
//
|
||||||
|
// The pure tests in tagoverlap_test.go cover the scoring maths. These cover the
|
||||||
|
// wiring the pure tests cannot reach: that loadTagInputs actually reads both
|
||||||
|
// sides from the DB and that the blend reaches the returned deck.
|
||||||
|
|
||||||
|
func seedTasteTag(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string, weight float64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, tag) DO UPDATE SET weight = EXCLUDED.weight`,
|
||||||
|
userID, tag, weight,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seedTasteTag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedCandidateTag(t *testing.T, pool *pgxpool.Pool, mbid, tag string, weight float64) {
|
||||||
|
t.Helper()
|
||||||
|
if err := dbq.New(pool).InsertCandidateArtistTag(context.Background(),
|
||||||
|
dbq.InsertCandidateArtistTagParams{CandidateMbid: mbid, Tag: tag, Weight: weight},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("seedCandidateTag: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// twoCandidatePool wires a liked seed with two unmatched neighbours: "loud"
|
||||||
|
// scores higher on similarity, "match" lower. Skewed on purpose — with equal
|
||||||
|
// similarity the reorder assertion below could not fail.
|
||||||
|
func twoCandidatePool(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
likeArtist(t, pool, userID, seed.ID)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "loud", "Loud Neighbour", 0.9)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "match", "Taste Match", 0.6)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuggestArtists_TasteTagMatchOvertakesStrongerSimilarity(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
twoCandidatePool(t, pool, user.ID)
|
||||||
|
|
||||||
|
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
|
||||||
|
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
|
||||||
|
seedCandidateTag(t, pool, "loud", "death metal", 1.0)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "match" {
|
||||||
|
t.Errorf("first = %q, want match (0.6×2 beats 0.9×1)", out[0].MBID)
|
||||||
|
}
|
||||||
|
// And it explains itself.
|
||||||
|
if len(out[0].MatchedTags) != 1 || out[0].MatchedTags[0] != "shoegaze" {
|
||||||
|
t.Errorf("matched tags = %v, want [shoegaze]", out[0].MatchedTags)
|
||||||
|
}
|
||||||
|
if out[1].MatchedTags != nil {
|
||||||
|
t.Errorf("non-matching candidate got matched tags: %v", out[1].MatchedTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same fixture with the knob at 0 must return pure similarity order — the
|
||||||
|
// operator's off switch, verified against a real DB rather than assumed.
|
||||||
|
func TestSuggestArtists_ZeroTagWeightKeepsSimilarityOrder(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
twoCandidatePool(t, pool, user.ID)
|
||||||
|
|
||||||
|
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
|
||||||
|
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if out[0].MBID != "loud" {
|
||||||
|
t.Errorf("first = %q, want loud (tag term disabled)", out[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user with no taste profile must still get a full deck in similarity order:
|
||||||
|
// the cold-start path, which is every user's first days (rule #131).
|
||||||
|
func TestSuggestArtists_NoTasteTagsStillReturnsSimilarityOrder(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
twoCandidatePool(t, pool, user.ID)
|
||||||
|
// Candidate tags exist, but the user has no taste tags to match them.
|
||||||
|
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2 (nothing dropped)", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "loud" {
|
||||||
|
t.Errorf("first = %q, want loud", out[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An untagged candidate must never be dropped or sunk just because another
|
||||||
|
// candidate has tags — permanently-partial coverage (#2376) must not become a
|
||||||
|
// permanent ranking penalty.
|
||||||
|
func TestSuggestArtists_UntaggedCandidateSurvivesAlongsideTagged(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
twoCandidatePool(t, pool, user.ID)
|
||||||
|
|
||||||
|
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
|
||||||
|
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
|
||||||
|
// "loud" is deliberately left with NO cached tags at all.
|
||||||
|
|
||||||
|
// Compared against the same request with the term disabled, rather than
|
||||||
|
// against a literal. The pool score is signal-weighted by the seed query
|
||||||
|
// (ln(1+signal) x similarity), so hardcoding a number here would assert
|
||||||
|
// against the seeding maths — a different layer — and break whenever that
|
||||||
|
// changes. The property under test is only that the blend leaves an
|
||||||
|
// untagged candidate alone.
|
||||||
|
blended, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists(blended): %v", err)
|
||||||
|
}
|
||||||
|
baseline, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists(baseline): %v", err)
|
||||||
|
}
|
||||||
|
if len(blended) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2 — the untagged candidate must still appear", len(blended))
|
||||||
|
}
|
||||||
|
|
||||||
|
scoreOf := func(out []ArtistSuggestion, mbid string) (float64, bool) {
|
||||||
|
for _, s := range out {
|
||||||
|
if s.MBID == mbid {
|
||||||
|
return s.Score, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
want, ok := scoreOf(baseline, "loud")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("untagged candidate missing from the baseline deck")
|
||||||
|
}
|
||||||
|
got, ok := scoreOf(blended, "loud")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("untagged candidate vanished once the tag term was enabled")
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("untagged score = %v, want %v (identical to the term-disabled run)", got, want)
|
||||||
|
}
|
||||||
|
// Sanity: the tagged candidate DID move, so the comparison above is
|
||||||
|
// meaningful rather than both runs being trivially identical.
|
||||||
|
if tagged, _ := scoreOf(blended, "match"); tagged == mustScore(t, baseline, "match") {
|
||||||
|
t.Error("the tagged candidate's score did not change — the term did nothing")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustScore(t *testing.T, out []ArtistSuggestion, mbid string) float64 {
|
||||||
|
t.Helper()
|
||||||
|
for _, s := range out {
|
||||||
|
if s.MBID == mbid {
|
||||||
|
return s.Score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no candidate %q in %v", mbid, out)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
// suggestions.go is the M5c per-user artist-suggestion service. Reads
|
// suggestions.go is the per-user artist-suggestion service behind the
|
||||||
// the user's likes + plays, projects them through artist_similarity_unmatched
|
// Discover request surface. Seeds from the taste profile (falling back to
|
||||||
// via a single CTE, returns top-N candidates with top-3 attribution seeds
|
// likes + completed plays for a user who has none yet), projects those seeds
|
||||||
// resolved to artist names.
|
// through artist_similarity_unmatched via a single CTE, and returns top-N
|
||||||
|
// out-of-library candidates with top-3 attribution seeds resolved to names.
|
||||||
|
//
|
||||||
|
// Originally M5c, which seeded from raw likes + plays. That signal grew
|
||||||
|
// without bound and counted skips as engagement, so a few heavily-played
|
||||||
|
// artists monopolized every slot and the surface entrenched harder the more
|
||||||
|
// the user listened. Reworked to seed from the taste profile in issue #2367;
|
||||||
|
// see internal/db/queries/recommendation.sql for the tiering.
|
||||||
package recommendation
|
package recommendation
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -20,6 +31,15 @@ type ArtistSuggestion struct {
|
|||||||
Name string
|
Name string
|
||||||
Score float64
|
Score float64
|
||||||
Attribution []SeedContribution
|
Attribution []SeedContribution
|
||||||
|
// MatchedTags are the candidate's own tags that overlap the user's taste
|
||||||
|
// profile, strongest first (max 3) — the "matches: shoegaze, melancholic"
|
||||||
|
// explanation (#2377). Empty when the candidate has no cached tags, which
|
||||||
|
// is common and not an error: coverage is permanently partial (#2376).
|
||||||
|
MatchedTags []string
|
||||||
|
// TagOverlap is the [0,1] share of the candidate's tag mass the user likes.
|
||||||
|
// Exposed for the admin tuning lab — seeing the term's actual distribution
|
||||||
|
// is how the operator picks a weight rather than guessing at one.
|
||||||
|
TagOverlap float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// SeedContribution is one of the top-3 contributing seeds for a candidate.
|
// SeedContribution is one of the top-3 contributing seeds for a candidate.
|
||||||
@@ -32,9 +52,16 @@ type SeedContribution struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
// SuggestArtists returns top-N artist suggestions for the user. limit is
|
||||||
// capped at 50 (default 12 when out of range); halfLifeDays is the
|
// capped at 50 (default 12 when out of range).
|
||||||
// recency-decay half-life for plays (default 30, operator-tunable).
|
//
|
||||||
func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int) ([]ArtistSuggestion, error) {
|
// halfLifeDays (default 30) is the recency-decay half-life for the TIER-2
|
||||||
|
// seed path only — the likes + completed-plays fallback used while the user
|
||||||
|
// has no taste-profile rows yet. Once the profile is populated it seeds
|
||||||
|
// instead, carrying its own decay, so this knob stops applying.
|
||||||
|
func SuggestArtists(
|
||||||
|
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||||
|
halfLifeDays float64, limit int, tagOverlapWeight float64,
|
||||||
|
) ([]ArtistSuggestion, error) {
|
||||||
if limit <= 0 || limit > 50 {
|
if limit <= 0 || limit > 50 {
|
||||||
limit = 12
|
limit = 12
|
||||||
}
|
}
|
||||||
@@ -42,10 +69,14 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
|||||||
halfLifeDays = 30
|
halfLifeDays = 30
|
||||||
}
|
}
|
||||||
q := dbq.New(pool)
|
q := dbq.New(pool)
|
||||||
|
// Over-fetch so there is something to rotate through. A deterministic
|
||||||
|
// top-N over a score ordering shows the same faces every day until one is
|
||||||
|
// requested away — the tail of the ranking was unreachable (#2367
|
||||||
|
// mechanism 1). Selection from this pool happens in selectSuggestions.
|
||||||
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
|
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Column2: halfLifeDays,
|
Column2: halfLifeDays,
|
||||||
Limit: int32(limit),
|
Limit: int32(poolSizeFor(limit)),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("suggest: query: %w", err)
|
return nil, fmt.Errorf("suggest: query: %w", err)
|
||||||
@@ -98,5 +129,195 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
|||||||
Attribution: attribution,
|
Attribution: attribution,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
|
||||||
|
// Taste-tag term (#2377). Applied to the whole over-fetched pool BEFORE
|
||||||
|
// selection, so the rotation and diversity rules in selectSuggestions
|
||||||
|
// operate on taste-blended scores — boosting only the twelve already
|
||||||
|
// chosen by similarity would leave the actual re-ranking undone.
|
||||||
|
//
|
||||||
|
// A query error here is returned, NOT degraded past. Graceful degradation
|
||||||
|
// is for expected absence — no taste profile yet, no cached tags for a
|
||||||
|
// candidate — and both of those are handled explicitly as empty inputs
|
||||||
|
// below. A failing query is neither: swallowing it would hide a broken DB
|
||||||
|
// behind a subtly worse ranking that nothing reports.
|
||||||
|
tasteTags, candTags, err := loadTagInputs(ctx, q, userID, out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = applyTagOverlap(out, candTags, tasteTags, tagOverlapWeight)
|
||||||
|
|
||||||
|
return selectSuggestions(out, limit, rotationDay(time.Now())), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tasteTagLimit caps how many of the user's taste tags participate. The
|
||||||
|
// profile's long tail is near-zero weight and contributes nothing after
|
||||||
|
// normalization, so this bounds the query rather than the meaning.
|
||||||
|
const tasteTagLimit = 50
|
||||||
|
|
||||||
|
// loadTagInputs fetches both sides of the overlap comparison: the user's taste
|
||||||
|
// tags and the cached tags for exactly the candidates in this pool.
|
||||||
|
func loadTagInputs(
|
||||||
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, pool []ArtistSuggestion,
|
||||||
|
) (TagWeights, map[string]TagWeights, error) {
|
||||||
|
tasteRows, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{
|
||||||
|
UserID: userID,
|
||||||
|
Limit: tasteTagLimit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("suggest: taste tags: %w", err)
|
||||||
|
}
|
||||||
|
// No taste tags is a cold start, not a failure — return early and skip the
|
||||||
|
// candidate-tag fetch entirely, since nothing could match.
|
||||||
|
if len(tasteRows) == 0 {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
taste := make(TagWeights, len(tasteRows))
|
||||||
|
for _, r := range tasteRows {
|
||||||
|
taste[r.Tag] = r.Weight
|
||||||
|
}
|
||||||
|
|
||||||
|
mbids := make([]string, 0, len(pool))
|
||||||
|
for _, s := range pool {
|
||||||
|
mbids = append(mbids, s.MBID)
|
||||||
|
}
|
||||||
|
tagRows, err := q.ListCandidateArtistTagsForMbids(ctx, mbids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("suggest: candidate tags: %w", err)
|
||||||
|
}
|
||||||
|
byCandidate := make(map[string]TagWeights, len(pool))
|
||||||
|
for _, r := range tagRows {
|
||||||
|
if byCandidate[r.CandidateMbid] == nil {
|
||||||
|
byCandidate[r.CandidateMbid] = TagWeights{}
|
||||||
|
}
|
||||||
|
byCandidate[r.CandidateMbid][r.Tag] = r.Weight
|
||||||
|
}
|
||||||
|
return taste, byCandidate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pool multiplier: how many scored candidates to fetch per slot shown, so the
|
||||||
|
// rotation has somewhere to rotate. 4x keeps a day's deck genuinely different
|
||||||
|
// from yesterday's without pulling the whole long tail (whose scores are noise)
|
||||||
|
// into every request.
|
||||||
|
const suggestionPoolFactor = 4
|
||||||
|
|
||||||
|
// Hard ceiling on the fetched pool. The scored tail past this is low-signal, so
|
||||||
|
// paying for it would buy churn rather than better suggestions.
|
||||||
|
const suggestionPoolMax = 60
|
||||||
|
|
||||||
|
func poolSizeFor(limit int) int {
|
||||||
|
n := limit * suggestionPoolFactor
|
||||||
|
if n > suggestionPoolMax {
|
||||||
|
return suggestionPoolMax
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotationDay is the bucket the daily rotation hashes against. Server-local
|
||||||
|
// date, matching the `current_date` the Home rows already rotate on, so the
|
||||||
|
// whole product turns over at the same moment.
|
||||||
|
func rotationDay(now time.Time) string {
|
||||||
|
return now.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectSuggestions turns the scored pool into the response.
|
||||||
|
//
|
||||||
|
// Pure by design — no DB, no clock — so the rotation and diversity rules are
|
||||||
|
// unit-testable without Postgres. Callers pass the day bucket in.
|
||||||
|
//
|
||||||
|
// Three rules, in order:
|
||||||
|
//
|
||||||
|
// 1. Diversity cap. Walking in score order, a candidate is dropped once its
|
||||||
|
// dominant seed already owns maxPerSeed slots. Nothing capped this before,
|
||||||
|
// so all twelve slots could be neighbours of one artist and the surface
|
||||||
|
// read as a single narrow cluster (#2367 mechanism 4).
|
||||||
|
// 2. Head. The best few by score always lead, so the strongest matches are
|
||||||
|
// never rotated out of sight. Mirrors For You's head/tail shape.
|
||||||
|
// 3. Tail. The rest of the slots are drawn from the remaining pool ordered by
|
||||||
|
// md5(mbid + day) — the same daily-stable idiom the Home rows use. Stable
|
||||||
|
// within a day, different tomorrow, and it needs no stored state.
|
||||||
|
func selectSuggestions(pool []ArtistSuggestion, limit int, day string) []ArtistSuggestion {
|
||||||
|
if len(pool) <= limit {
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
preferred, overflow := partitionPerSeed(pool, maxPerSeedFor(limit))
|
||||||
|
|
||||||
|
headCount := limit / 3
|
||||||
|
if headCount > len(preferred) {
|
||||||
|
headCount = len(preferred)
|
||||||
|
}
|
||||||
|
out := make([]ArtistSuggestion, 0, limit)
|
||||||
|
out = append(out, preferred[:headCount]...)
|
||||||
|
|
||||||
|
// Hash once per candidate, not once per comparison.
|
||||||
|
rest := preferred[headCount:]
|
||||||
|
tail := make([]rotatable, 0, len(rest))
|
||||||
|
for _, s := range rest {
|
||||||
|
tail = append(tail, rotatable{key: rotationKey(s.MBID, day), suggestion: s})
|
||||||
|
}
|
||||||
|
sort.SliceStable(tail, func(i, j int) bool { return tail[i].key < tail[j].key })
|
||||||
|
for _, r := range tail {
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, r.suggestion)
|
||||||
|
}
|
||||||
|
// Diversity is a PREFERENCE, not a quota that may starve the deck. A user
|
||||||
|
// whose whole pool hangs off one or two seeds would otherwise get a
|
||||||
|
// three-card surface, which is worse than the monoculture we were avoiding
|
||||||
|
// (and is the vanish-or-nothing shape rule #131 exists to prevent). Top up
|
||||||
|
// in score order from what the cap set aside.
|
||||||
|
for _, s := range overflow {
|
||||||
|
if len(out) >= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// rotatable pairs a candidate with its precomputed daily rotation key.
|
||||||
|
type rotatable struct {
|
||||||
|
key string
|
||||||
|
suggestion ArtistSuggestion
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPerSeedFor keeps roughly a quarter of the deck attributable to any single
|
||||||
|
// seed artist — enough for a strong affinity to be well represented, not enough
|
||||||
|
// for it to BE the deck. Floored at 1 so a small limit still returns something.
|
||||||
|
func maxPerSeedFor(limit int) int {
|
||||||
|
n := limit / 4
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// partitionPerSeed splits the pool by whether each candidate's dominant
|
||||||
|
// (highest-contributing) seed still has allowance left. Input must be in score
|
||||||
|
// order; both outputs preserve it.
|
||||||
|
//
|
||||||
|
// Candidates with no attribution are always preferred — there is no seed to
|
||||||
|
// attribute them to, so they cannot be the cause of a monoculture.
|
||||||
|
func partitionPerSeed(pool []ArtistSuggestion, maxPerSeed int) (preferred, overflow []ArtistSuggestion) {
|
||||||
|
perSeed := make(map[pgtype.UUID]int, len(pool))
|
||||||
|
preferred = make([]ArtistSuggestion, 0, len(pool))
|
||||||
|
for _, s := range pool {
|
||||||
|
if len(s.Attribution) == 0 {
|
||||||
|
preferred = append(preferred, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dominant := s.Attribution[0].ArtistID
|
||||||
|
if perSeed[dominant] >= maxPerSeed {
|
||||||
|
overflow = append(overflow, s)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
perSeed[dominant]++
|
||||||
|
preferred = append(preferred, s)
|
||||||
|
}
|
||||||
|
return preferred, overflow
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotationKey(mbid, day string) string {
|
||||||
|
sum := md5.Sum([]byte(mbid + day))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) {
|
|||||||
seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9)
|
seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9)
|
||||||
seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5)
|
seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5)
|
||||||
|
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -174,7 +174,7 @@ func TestSuggestArtists_Top12Cap(t *testing.T) {
|
|||||||
for i := 0; i < 30; i++ {
|
for i := 0; i < 30; i++ {
|
||||||
seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01)
|
seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01)
|
||||||
}
|
}
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -195,7 +195,7 @@ func TestSuggestArtists_AttributionTopThree(t *testing.T) {
|
|||||||
likeArtist(t, pool, user.ID, seeds[i].ID)
|
likeArtist(t, pool, user.ID, seeds[i].ID)
|
||||||
seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1)
|
seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1)
|
||||||
}
|
}
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -227,7 +227,7 @@ func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) {
|
|||||||
seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5)
|
seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5)
|
||||||
seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5)
|
seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5)
|
||||||
|
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) {
|
|||||||
seedArtist(t, pool, "InLib", inLibMBID)
|
seedArtist(t, pool, "InLib", inLibMBID)
|
||||||
seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9)
|
seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9)
|
||||||
|
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
|
|||||||
t.Fatalf("CreateLidarrRequest: %v", err)
|
t.Fatalf("CreateLidarrRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -310,7 +310,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
|
|||||||
t.Fatalf("RejectLidarrRequest: %v", err)
|
t.Fatalf("RejectLidarrRequest: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -322,7 +322,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
|
|||||||
func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
user := seedUser(t, pool, "newbie")
|
user := seedUser(t, pool, "newbie")
|
||||||
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12)
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SuggestArtists: %v", err)
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
}
|
}
|
||||||
@@ -330,3 +330,359 @@ func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
|||||||
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
t.Errorf("len = %d, want 0 (new user has no signal)", len(out))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
|
||||||
|
|
||||||
|
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, artist_id) DO UPDATE SET weight = EXCLUDED.weight`,
|
||||||
|
userID, artistID, weight,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("set taste weight: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertSkippedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var sessionID pgtype.UUID
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||||
|
VALUES ($1, $2, $2, 'skip-test') RETURNING id`,
|
||||||
|
userID, startedAt,
|
||||||
|
).Scan(&sessionID); err != nil {
|
||||||
|
t.Fatalf("insert play_session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
|
||||||
|
VALUES ($1, $2, $3, $4, true)`,
|
||||||
|
userID, trackID, sessionID, startedAt,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("insert skipped play_event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
|
||||||
|
// play on the seed artist. Before #2367 the profile was ignored entirely here.
|
||||||
|
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seed := seedArtist(t, pool, "Profile Seed", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (taste weight alone should seed)", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "out-mbid" {
|
||||||
|
t.Errorf("mbid = %q, want out-mbid", out[0].MBID)
|
||||||
|
}
|
||||||
|
if out[0].Score <= 0 {
|
||||||
|
t.Errorf("score = %v, want > 0", out[0].Score)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the profile has any positive row, tier 2 is not consulted — so an artist
|
||||||
|
// the user played but that the taste engine did not keep does NOT seed. That is
|
||||||
|
// the point of the rewrite: the taste engine decides what counts as affinity.
|
||||||
|
func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
kept := seedArtist(t, pool, "Kept By Taste", "")
|
||||||
|
dropped := seedArtist(t, pool, "Dropped By Taste", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, kept.ID, 3.0)
|
||||||
|
|
||||||
|
// `dropped` has real completed plays but no profile row.
|
||||||
|
album := seedAlbumForArtist(t, pool, dropped.ID, "Album")
|
||||||
|
track := seedTrackOnAlbum(t, pool, album.ID, dropped.ID, "Track")
|
||||||
|
insertPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-1*time.Hour))
|
||||||
|
|
||||||
|
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
|
||||||
|
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (tier 2 must not run alongside tier 1)", len(out))
|
||||||
|
}
|
||||||
|
if out[0].MBID != "kept-cand" {
|
||||||
|
t.Errorf("mbid = %q, want kept-cand", out[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-positive taste weight is not affinity. Guarded by a second, positive
|
||||||
|
// row so tier 1 stays active — otherwise an empty tier 1 would fall through to
|
||||||
|
// tier 2 and the assertion would pass for the wrong reason.
|
||||||
|
func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
positive := seedArtist(t, pool, "Still Liked", "")
|
||||||
|
abandoned := seedArtist(t, pool, "Abandoned", "")
|
||||||
|
|
||||||
|
setTasteWeight(t, pool, user.ID, positive.ID, 2.0)
|
||||||
|
setTasteWeight(t, pool, user.ID, abandoned.ID, -1.5)
|
||||||
|
|
||||||
|
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
|
||||||
|
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
for _, s := range out {
|
||||||
|
if s.MBID == "neg-cand" {
|
||||||
|
t.Fatalf("negative-weight artist seeded a suggestion: %+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) != 1 || out[0].MBID != "pos-cand" {
|
||||||
|
t.Errorf("out = %+v, want only pos-cand", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
|
||||||
|
// skipping an artist repeatedly increased its signal and pushed more of its
|
||||||
|
// neighbours at the user (#2367 mechanism 3).
|
||||||
|
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
skipped := seedArtist(t, pool, "Only Skipped", "")
|
||||||
|
|
||||||
|
album := seedAlbumForArtist(t, pool, skipped.ID, "Album")
|
||||||
|
track := seedTrackOnAlbum(t, pool, album.ID, skipped.ID, "Track")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
insertSkippedPlayEvent(t, pool, user.ID, track.ID, time.Now().Add(-time.Duration(i+1)*time.Hour))
|
||||||
|
}
|
||||||
|
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0 (skips are not affinity): %+v", len(out), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Slice 3 (#2374): time-boxed suggestion snooze ---
|
||||||
|
//
|
||||||
|
// The defining property under test is that a snooze EXPIRES. A permanent
|
||||||
|
// dismissal would pass most of these; only TestSuggestArtists_ExpiredSnooze
|
||||||
|
// distinguishes the two, and it is the reason this shape was approved over an
|
||||||
|
// exclusion UI (rule #101).
|
||||||
|
|
||||||
|
// snoozeUntil inserts a snooze row with an explicit absolute expiry, so a
|
||||||
|
// test can place it in the past without depending on the duration arithmetic
|
||||||
|
// in SnoozeSuggestion.
|
||||||
|
func snoozeUntil(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, mbid, name string, until time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO suggestion_snoozes (user_id, candidate_mbid, candidate_name, snoozed_until)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (user_id, candidate_mbid) DO UPDATE SET snoozed_until = EXCLUDED.snoozed_until`,
|
||||||
|
userID, mbid, name, until,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("snoozeUntil: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedOneCandidate wires the minimum that puts exactly one candidate in the
|
||||||
|
// deck: a liked seed artist with one unmatched neighbour.
|
||||||
|
func seedOneCandidate(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, mbid, name string) {
|
||||||
|
t.Helper()
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
likeArtist(t, pool, userID, seed.ID)
|
||||||
|
seedUnmatched(t, pool, seed.ID, mbid, name, 0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSuggestArtists_ActiveSnoozeHidesCandidate(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seedOneCandidate(t, pool, user.ID, "snoozed-mbid", "Parked Artist")
|
||||||
|
|
||||||
|
if err := dbq.New(pool).SnoozeSuggestion(context.Background(), dbq.SnoozeSuggestionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CandidateMbid: "snoozed-mbid",
|
||||||
|
CandidateName: "Parked Artist",
|
||||||
|
Column4: 90,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SnoozeSuggestion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Errorf("len = %d, want 0 (active snooze should hide the candidate): %+v", len(out), out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The whole point of a snooze over a dismissal: it comes back on its own.
|
||||||
|
func TestSuggestArtists_ExpiredSnoozeShowsCandidateAgain(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seedOneCandidate(t, pool, user.ID, "expired-mbid", "Returning Artist")
|
||||||
|
snoozeUntil(t, pool, user.ID, "expired-mbid", "Returning Artist", time.Now().Add(-time.Hour))
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (an expired snooze must not hide anything): %+v", len(out), out)
|
||||||
|
}
|
||||||
|
if out[0].MBID != "expired-mbid" {
|
||||||
|
t.Errorf("mbid = %q, want expired-mbid", out[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule #47: one household member parking a suggestion must not remove it
|
||||||
|
// from anyone else's deck.
|
||||||
|
func TestSuggestArtists_SnoozeIsPerUser(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
alice := seedUser(t, pool, "alice")
|
||||||
|
bob := seedUser(t, pool, "bob")
|
||||||
|
|
||||||
|
seed := seedArtist(t, pool, "Seed", "")
|
||||||
|
likeArtist(t, pool, alice.ID, seed.ID)
|
||||||
|
likeArtist(t, pool, bob.ID, seed.ID)
|
||||||
|
seedUnmatched(t, pool, seed.ID, "shared-mbid", "Shared Candidate", 0.9)
|
||||||
|
|
||||||
|
snoozeUntil(t, pool, alice.ID, "shared-mbid", "Shared Candidate", time.Now().Add(24*time.Hour))
|
||||||
|
|
||||||
|
aliceOut, err := SuggestArtists(context.Background(), pool, alice.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists(alice): %v", err)
|
||||||
|
}
|
||||||
|
if len(aliceOut) != 0 {
|
||||||
|
t.Errorf("alice len = %d, want 0 (she snoozed it)", len(aliceOut))
|
||||||
|
}
|
||||||
|
bobOut, err := SuggestArtists(context.Background(), pool, bob.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists(bob): %v", err)
|
||||||
|
}
|
||||||
|
if len(bobOut) != 1 {
|
||||||
|
t.Errorf("bob len = %d, want 1 (alice's snooze is not his)", len(bobOut))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsnoozeSuggestion_RestoresImmediately(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
seedOneCandidate(t, pool, user.ID, "undo-mbid", "Undo Artist")
|
||||||
|
snoozeUntil(t, pool, user.ID, "undo-mbid", "Undo Artist", time.Now().Add(90*24*time.Hour))
|
||||||
|
|
||||||
|
q := dbq.New(pool)
|
||||||
|
rows, err := q.UnsnoozeSuggestion(context.Background(), dbq.UnsnoozeSuggestionParams{
|
||||||
|
UserID: user.ID, CandidateMbid: "undo-mbid",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnsnoozeSuggestion: %v", err)
|
||||||
|
}
|
||||||
|
if rows != 1 {
|
||||||
|
t.Errorf("rows = %d, want 1", rows)
|
||||||
|
}
|
||||||
|
// A second delete affects nothing — the handler turns this into a 404
|
||||||
|
// rather than reporting success for a no-op.
|
||||||
|
rows, err = q.UnsnoozeSuggestion(context.Background(), dbq.UnsnoozeSuggestionParams{
|
||||||
|
UserID: user.ID, CandidateMbid: "undo-mbid",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("UnsnoozeSuggestion (repeat): %v", err)
|
||||||
|
}
|
||||||
|
if rows != 0 {
|
||||||
|
t.Errorf("repeat rows = %d, want 0", rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SuggestArtists: %v", err)
|
||||||
|
}
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Errorf("len = %d, want 1 (unsnooze restores the candidate)", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-snoozing must extend, not conflict on the PK.
|
||||||
|
func TestSnoozeSuggestion_UpsertExtends(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
snoozeUntil(t, pool, user.ID, "extend-mbid", "Old Name", time.Now().Add(time.Hour))
|
||||||
|
if err := q.SnoozeSuggestion(context.Background(), dbq.SnoozeSuggestionParams{
|
||||||
|
UserID: user.ID,
|
||||||
|
CandidateMbid: "extend-mbid",
|
||||||
|
CandidateName: "New Name",
|
||||||
|
Column4: 90,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("SnoozeSuggestion (re-snooze): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.ListActiveSuggestionSnoozes(context.Background(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (upsert, not a second row)", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].CandidateName != "New Name" {
|
||||||
|
t.Errorf("name = %q, want New Name (upsert refreshes it)", rows[0].CandidateName)
|
||||||
|
}
|
||||||
|
if got := time.Until(rows[0].SnoozedUntil.Time); got < 80*24*time.Hour {
|
||||||
|
t.Errorf("snoozed_until is %v away, want ~90d (re-snooze should extend)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveSuggestionSnoozes filters expired rows itself rather than
|
||||||
|
// trusting the hourly gc sweep to have run.
|
||||||
|
func TestListActiveSuggestionSnoozes_ExcludesExpired(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
snoozeUntil(t, pool, user.ID, "live-mbid", "Live", time.Now().Add(24*time.Hour))
|
||||||
|
snoozeUntil(t, pool, user.ID, "dead-mbid", "Dead", time.Now().Add(-24*time.Hour))
|
||||||
|
|
||||||
|
rows, err := dbq.New(pool).ListActiveSuggestionSnoozes(context.Background(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("len = %d, want 1 (expired row must not be listed)", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].CandidateMbid != "live-mbid" {
|
||||||
|
t.Errorf("mbid = %q, want live-mbid", rows[0].CandidateMbid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGcDeleteExpiredSuggestionSnoozes_KeepsActive(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "alice")
|
||||||
|
snoozeUntil(t, pool, user.ID, "live-mbid", "Live", time.Now().Add(24*time.Hour))
|
||||||
|
snoozeUntil(t, pool, user.ID, "dead-mbid", "Dead", time.Now().Add(-24*time.Hour))
|
||||||
|
|
||||||
|
q := dbq.New(pool)
|
||||||
|
deleted, err := q.GcDeleteExpiredSuggestionSnoozes(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GcDeleteExpiredSuggestionSnoozes: %v", err)
|
||||||
|
}
|
||||||
|
if deleted != 1 {
|
||||||
|
t.Errorf("deleted = %d, want 1 (only the expired row)", deleted)
|
||||||
|
}
|
||||||
|
rows, err := q.ListActiveSuggestionSnoozes(context.Background(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListActiveSuggestionSnoozes: %v", err)
|
||||||
|
}
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Errorf("len = %d, want 1 (active snooze survives the sweep)", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// selectSuggestions is deliberately pure (no DB, no clock), so the rotation and
|
||||||
|
// diversity rules from slice #2373 are covered here in the fast lane rather
|
||||||
|
// than behind the integration gate.
|
||||||
|
|
||||||
|
func seedID(n byte) pgtype.UUID {
|
||||||
|
var u pgtype.UUID
|
||||||
|
u.Bytes[0] = n
|
||||||
|
u.Valid = true
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidate builds a pool entry attributed to the given dominant seed.
|
||||||
|
func candidate(mbid string, score float64, dominant byte) ArtistSuggestion {
|
||||||
|
return ArtistSuggestion{
|
||||||
|
MBID: mbid,
|
||||||
|
Name: mbid,
|
||||||
|
Score: score,
|
||||||
|
Attribution: []SeedContribution{
|
||||||
|
{ArtistID: seedID(dominant), Contribution: score},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// poolOf returns n candidates in descending score order, spread across
|
||||||
|
// `seeds` distinct dominant seeds.
|
||||||
|
func poolOf(n int, seeds int) []ArtistSuggestion {
|
||||||
|
out := make([]ArtistSuggestion, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
out = append(out, candidate(fmt.Sprintf("mbid-%02d", i), 1.0-float64(i)*0.01, byte(i%seeds)))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mbids(in []ArtistSuggestion) []string {
|
||||||
|
out := make([]string, 0, len(in))
|
||||||
|
for _, s := range in {
|
||||||
|
out = append(out, s.MBID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectSuggestions_ReturnsPoolUnchangedWhenNotOverfetched(t *testing.T) {
|
||||||
|
pool := poolOf(5, 5)
|
||||||
|
got := selectSuggestions(pool, 12, "2026-08-01")
|
||||||
|
if len(got) != 5 {
|
||||||
|
t.Fatalf("len = %d, want 5 (nothing to rotate)", len(got))
|
||||||
|
}
|
||||||
|
if got[0].MBID != "mbid-00" {
|
||||||
|
t.Errorf("first = %q, want mbid-00", got[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectSuggestions_FillsTheRequestedLimit(t *testing.T) {
|
||||||
|
got := selectSuggestions(poolOf(48, 8), 12, "2026-08-01")
|
||||||
|
if len(got) != 12 {
|
||||||
|
t.Fatalf("len = %d, want 12", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reported symptom: the deck must change day to day without the user
|
||||||
|
// requesting anything.
|
||||||
|
func TestSelectSuggestions_RotatesAcrossDays(t *testing.T) {
|
||||||
|
pool := poolOf(48, 8)
|
||||||
|
day1 := mbids(selectSuggestions(pool, 12, "2026-08-01"))
|
||||||
|
day2 := mbids(selectSuggestions(pool, 12, "2026-08-02"))
|
||||||
|
|
||||||
|
if fmt.Sprint(day1) == fmt.Sprint(day2) {
|
||||||
|
t.Fatalf("deck identical across days: %v", day1)
|
||||||
|
}
|
||||||
|
// ...but stable WITHIN a day, or the surface would reshuffle on every
|
||||||
|
// pull-to-refresh, which reads as broken rather than fresh.
|
||||||
|
again := mbids(selectSuggestions(pool, 12, "2026-08-01"))
|
||||||
|
if fmt.Sprint(day1) != fmt.Sprint(again) {
|
||||||
|
t.Errorf("same day differed:\n %v\n %v", day1, again)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The strongest matches should never rotate out of sight.
|
||||||
|
func TestSelectSuggestions_HeadIsStableTopScorers(t *testing.T) {
|
||||||
|
pool := poolOf(48, 8)
|
||||||
|
for _, day := range []string{"2026-08-01", "2026-08-02", "2026-09-15"} {
|
||||||
|
got := selectSuggestions(pool, 12, day)
|
||||||
|
if got[0].MBID != "mbid-00" {
|
||||||
|
t.Errorf("day %s: first = %q, want mbid-00 (top score leads)", day, got[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #2367 mechanism 4: all twelve slots could be neighbours of one artist.
|
||||||
|
//
|
||||||
|
// The pool is deliberately SKEWED — one seed owns the entire top of the score
|
||||||
|
// ranking — because that is the only shape where a cap can bite. With scores
|
||||||
|
// spread evenly across seeds the top-N is already diverse and the cap is
|
||||||
|
// untestable (an earlier version of this test asserted against an even pool and
|
||||||
|
// could not fail).
|
||||||
|
func TestSelectSuggestions_CapsOneDominantSeed(t *testing.T) {
|
||||||
|
const dominantRun = 20
|
||||||
|
pool := make([]ArtistSuggestion, 0, 48)
|
||||||
|
for i := 0; i < dominantRun; i++ { // seed 0 owns the 20 best scores
|
||||||
|
pool = append(pool, candidate(fmt.Sprintf("dom-%02d", i), 1.0-float64(i)*0.01, 0))
|
||||||
|
}
|
||||||
|
for i := 0; i < 28; i++ { // seeds 1..9 hold everything below
|
||||||
|
pool = append(pool, candidate(fmt.Sprintf("oth-%02d", i), 0.80-float64(i)*0.01, byte(1+i%9)))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := selectSuggestions(pool, 12, "2026-08-01")
|
||||||
|
if len(got) != 12 {
|
||||||
|
t.Fatalf("len = %d, want 12", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
perSeed := map[pgtype.UUID]int{}
|
||||||
|
for _, s := range got {
|
||||||
|
perSeed[s.Attribution[0].ArtistID]++
|
||||||
|
}
|
||||||
|
// Uncapped, the top 12 by score would be 12 of seed 0's neighbours.
|
||||||
|
if n := perSeed[seedID(0)]; n > maxPerSeedFor(12) {
|
||||||
|
t.Errorf("dominant seed owns %d of 12 slots, want <= %d: %v",
|
||||||
|
n, maxPerSeedFor(12), mbids(got))
|
||||||
|
}
|
||||||
|
if len(perSeed) < 4 {
|
||||||
|
t.Errorf("deck spans only %d seeds, want >= 4: %v", len(perSeed), mbids(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diversity must not starve the deck (rule #131): a pool hanging off a single
|
||||||
|
// seed should still fill, not collapse to maxPerSeed entries.
|
||||||
|
func TestSelectSuggestions_SingleSeedPoolStillFills(t *testing.T) {
|
||||||
|
got := selectSuggestions(poolOf(30, 1), 12, "2026-08-01")
|
||||||
|
if len(got) != 12 {
|
||||||
|
t.Fatalf("len = %d, want 12 (cap must not starve the deck)", len(got))
|
||||||
|
}
|
||||||
|
if got[0].MBID != "mbid-00" {
|
||||||
|
t.Errorf("first = %q, want mbid-00", got[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A candidate with no attribution has no seed to blame, so it must never be
|
||||||
|
// held back by the diversity cap.
|
||||||
|
func TestPartitionPerSeed_UnattributedNeverCapped(t *testing.T) {
|
||||||
|
pool := []ArtistSuggestion{
|
||||||
|
candidate("a", 0.9, 1),
|
||||||
|
candidate("b", 0.8, 1),
|
||||||
|
{MBID: "orphan", Score: 0.1},
|
||||||
|
}
|
||||||
|
preferred, overflow := partitionPerSeed(pool, 1)
|
||||||
|
if len(preferred) != 2 || preferred[1].MBID != "orphan" {
|
||||||
|
t.Errorf("preferred = %v, want [a orphan]", mbids(preferred))
|
||||||
|
}
|
||||||
|
if len(overflow) != 1 || overflow[0].MBID != "b" {
|
||||||
|
t.Errorf("overflow = %v, want [b]", mbids(overflow))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPoolSizeFor_OverfetchesButIsBounded(t *testing.T) {
|
||||||
|
if got := poolSizeFor(12); got != 48 {
|
||||||
|
t.Errorf("poolSizeFor(12) = %d, want 48", got)
|
||||||
|
}
|
||||||
|
if got := poolSizeFor(50); got != suggestionPoolMax {
|
||||||
|
t.Errorf("poolSizeFor(50) = %d, want the %d ceiling", got, suggestionPoolMax)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// tagoverlap.go — the taste-tag term for the Discover request surface
|
||||||
|
// (#2377, milestone #268 slice 6).
|
||||||
|
//
|
||||||
|
// Slices 1-2 made the deck stop repeating; this is what makes it *relevant*.
|
||||||
|
// Before this, a candidate's only claim on a slot was "some artist you play is
|
||||||
|
// similar to it" — a graph-adjacency fact that says nothing about whether the
|
||||||
|
// music sounds like anything you actually like. Here the candidate's own
|
||||||
|
// folksonomy tags (cached by slice 5) are compared against the user's
|
||||||
|
// taste-profile tags, so the surface can rank on "matches the sound you like"
|
||||||
|
// and say WHY.
|
||||||
|
//
|
||||||
|
// Pure by design — no DB, no clock — so the scoring rules are unit-testable in
|
||||||
|
// the fast lane rather than behind the integration gate.
|
||||||
|
package recommendation
|
||||||
|
|
||||||
|
import "sort"
|
||||||
|
|
||||||
|
// maxMatchedTags caps the "matches: …" explanation. Three is what the existing
|
||||||
|
// seed attribution shows, and a longer list stops being a reason and becomes a
|
||||||
|
// tag dump.
|
||||||
|
const maxMatchedTags = 3
|
||||||
|
|
||||||
|
// TagWeights is a tag → weight map. Both sides of the comparison use it:
|
||||||
|
// candidate tags (normalized [0,1] by the enrichment providers) and the user's
|
||||||
|
// taste-profile tags (accumulated, unbounded — normalized here).
|
||||||
|
type TagWeights map[string]float64
|
||||||
|
|
||||||
|
// tagOverlap scores how much of a candidate's tag identity the user actually
|
||||||
|
// likes, in [0,1], and returns the matched tags ordered by contribution.
|
||||||
|
//
|
||||||
|
// The measure is: of this candidate's total tag mass, what share sits on tags
|
||||||
|
// the user likes — each weighted by how strongly they like it?
|
||||||
|
//
|
||||||
|
// overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight
|
||||||
|
//
|
||||||
|
// Normalizing the taste side by the user's STRONGEST tag is what makes this
|
||||||
|
// comparable across users: taste weights accumulate with listening, so a
|
||||||
|
// heavy listener's raw numbers dwarf a new user's while meaning the same
|
||||||
|
// thing — "this is my favourite tag". Dividing by the candidate's own total
|
||||||
|
// mass makes it comparable across candidates, so a densely-tagged artist
|
||||||
|
// can't out-score a sparsely-tagged one just by having more tags.
|
||||||
|
//
|
||||||
|
// Returns (0, nil) when either side is empty. That is the load-bearing
|
||||||
|
// degradation path: tag coverage for out-of-library candidates is permanently
|
||||||
|
// partial (#2376), and a cold-start user has no taste tags at all. Both must
|
||||||
|
// leave the candidate's similarity score untouched rather than sink it —
|
||||||
|
// rule #131, tiered degradation, never vanish-or-nothing.
|
||||||
|
func tagOverlap(candidate, taste TagWeights) (float64, []string) {
|
||||||
|
if len(candidate) == 0 || len(taste) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
maxTaste := 0.0
|
||||||
|
for _, w := range taste {
|
||||||
|
if w > maxTaste {
|
||||||
|
maxTaste = w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every taste weight <= 0 carries no preference to match against. Guarding
|
||||||
|
// here also avoids dividing by zero below.
|
||||||
|
if maxTaste <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
totalMass := 0.0
|
||||||
|
for _, w := range candidate {
|
||||||
|
// Negative or zero candidate weights would let a tag subtract from the
|
||||||
|
// denominator and inflate the ratio past 1.
|
||||||
|
if w > 0 {
|
||||||
|
totalMass += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if totalMass <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type contribution struct {
|
||||||
|
tag string
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
var matched []contribution
|
||||||
|
sum := 0.0
|
||||||
|
for tag, candWeight := range candidate {
|
||||||
|
if candWeight <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tasteWeight, ok := taste[tag]
|
||||||
|
if !ok || tasteWeight <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := candWeight * (tasteWeight / maxTaste)
|
||||||
|
sum += c
|
||||||
|
matched = append(matched, contribution{tag: tag, score: c})
|
||||||
|
}
|
||||||
|
if len(matched) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strongest contribution first; tag name breaks ties so the explanation is
|
||||||
|
// deterministic for a given input rather than map-iteration order.
|
||||||
|
sort.Slice(matched, func(i, j int) bool {
|
||||||
|
if matched[i].score != matched[j].score {
|
||||||
|
return matched[i].score > matched[j].score
|
||||||
|
}
|
||||||
|
return matched[i].tag < matched[j].tag
|
||||||
|
})
|
||||||
|
names := make([]string, 0, min(len(matched), maxMatchedTags))
|
||||||
|
for i := 0; i < len(matched) && i < maxMatchedTags; i++ {
|
||||||
|
names = append(names, matched[i].tag)
|
||||||
|
}
|
||||||
|
return sum / totalMass, names
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyTagOverlap re-scores and re-orders a candidate pool by taste-tag
|
||||||
|
// overlap, stamping the matched tags onto each suggestion for the UI.
|
||||||
|
//
|
||||||
|
// The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — not additive,
|
||||||
|
// and the difference is the whole safety argument:
|
||||||
|
//
|
||||||
|
// - A candidate with no tags has overlap 0, so its score is EXACTLY
|
||||||
|
// unchanged. Partial tag coverage costs a candidate nothing.
|
||||||
|
// - Nothing can leapfrog on tags alone. An additive term with a large
|
||||||
|
// weight would let a near-zero-similarity artist outrank a strong match
|
||||||
|
// just for sharing a popular tag, which reads as noise to the user.
|
||||||
|
// - weight 0 disables the feature completely and restores pure similarity
|
||||||
|
// order, so the operator's knob has a real off position.
|
||||||
|
//
|
||||||
|
// Callers must pass the pool in similarity order; it is returned in blended
|
||||||
|
// order. Mutates the elements in place (they're the caller's own slice built
|
||||||
|
// per request), and re-sorts, because selectSuggestions downstream relies on
|
||||||
|
// score order for its head/tail split.
|
||||||
|
func applyTagOverlap(
|
||||||
|
pool []ArtistSuggestion, candidateTags map[string]TagWeights, taste TagWeights, weight float64,
|
||||||
|
) []ArtistSuggestion {
|
||||||
|
// A zero weight is the operator turning the feature off. Skip the work
|
||||||
|
// AND the re-sort so the ordering is bit-for-bit the pre-slice-6 result.
|
||||||
|
if weight == 0 || len(taste) == 0 {
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
for i := range pool {
|
||||||
|
overlap, matched := tagOverlap(candidateTags[pool[i].MBID], taste)
|
||||||
|
pool[i].MatchedTags = matched
|
||||||
|
pool[i].TagOverlap = overlap
|
||||||
|
pool[i].Score *= 1 + weight*overlap
|
||||||
|
}
|
||||||
|
sort.SliceStable(pool, func(i, j int) bool {
|
||||||
|
if pool[i].Score != pool[j].Score {
|
||||||
|
return pool[i].Score > pool[j].Score
|
||||||
|
}
|
||||||
|
// Stable tiebreak by MBID. Without it, two candidates on equal scores
|
||||||
|
// could swap between requests within the same day, which the daily
|
||||||
|
// rotation (#2373) exists to prevent.
|
||||||
|
return pool[i].MBID < pool[j].MBID
|
||||||
|
})
|
||||||
|
return pool
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagOverlap / applyTagOverlap are pure, so slice 6's ranking rules are covered
|
||||||
|
// in the fast lane rather than behind the integration gate.
|
||||||
|
//
|
||||||
|
// Fixtures here are deliberately SKEWED — candidates that match the taste
|
||||||
|
// profile to clearly different degrees. An evenly-matching pool cannot exercise
|
||||||
|
// a re-ranking at all: every candidate gets the same multiplier and the order is
|
||||||
|
// unchanged whether the blend works or not. Slice 2's first diversity test had
|
||||||
|
// exactly that defect, so it's called out explicitly here.
|
||||||
|
|
||||||
|
func TestTagOverlap_FullMatchScoresOne(t *testing.T) {
|
||||||
|
// Every unit of the candidate's tag mass sits on the user's single
|
||||||
|
// strongest tag → the whole mass matches at full strength.
|
||||||
|
got, matched := tagOverlap(
|
||||||
|
TagWeights{"shoegaze": 1.0},
|
||||||
|
TagWeights{"shoegaze": 5.0},
|
||||||
|
)
|
||||||
|
if got != 1.0 {
|
||||||
|
t.Errorf("overlap = %v, want 1.0", got)
|
||||||
|
}
|
||||||
|
if len(matched) != 1 || matched[0] != "shoegaze" {
|
||||||
|
t.Errorf("matched = %v, want [shoegaze]", matched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagOverlap_NoSharedTagsScoresZero(t *testing.T) {
|
||||||
|
got, matched := tagOverlap(
|
||||||
|
TagWeights{"death metal": 1.0},
|
||||||
|
TagWeights{"shoegaze": 5.0},
|
||||||
|
)
|
||||||
|
if got != 0 {
|
||||||
|
t.Errorf("overlap = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if matched != nil {
|
||||||
|
t.Errorf("matched = %v, want nil", matched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Half the candidate's mass is on a matching tag, and that tag is the user's
|
||||||
|
// strongest → 0.5.
|
||||||
|
func TestTagOverlap_PartialMassMatchIsProportional(t *testing.T) {
|
||||||
|
got, _ := tagOverlap(
|
||||||
|
TagWeights{"shoegaze": 1.0, "death metal": 1.0},
|
||||||
|
TagWeights{"shoegaze": 5.0},
|
||||||
|
)
|
||||||
|
if got != 0.5 {
|
||||||
|
t.Errorf("overlap = %v, want 0.5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matching a tag the user barely likes must score below matching one they love.
|
||||||
|
func TestTagOverlap_WeakTasteTagScoresLowerThanStrong(t *testing.T) {
|
||||||
|
taste := TagWeights{"shoegaze": 10.0, "polka": 1.0}
|
||||||
|
strong, _ := tagOverlap(TagWeights{"shoegaze": 1.0}, taste)
|
||||||
|
weak, _ := tagOverlap(TagWeights{"polka": 1.0}, taste)
|
||||||
|
if !(strong > weak) {
|
||||||
|
t.Errorf("strong=%v weak=%v — a favourite tag must outscore a marginal one", strong, weak)
|
||||||
|
}
|
||||||
|
if weak != 0.1 { // 1.0 * (1/10) / 1.0
|
||||||
|
t.Errorf("weak = %v, want 0.1", weak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// THE degradation path. Tag coverage for out-of-library candidates is
|
||||||
|
// permanently partial (#2376) and cold-start users have no taste tags, so both
|
||||||
|
// must be scored 0 — never negative, never dropped (rule #131).
|
||||||
|
func TestTagOverlap_EmptyEitherSideIsZeroNotNegative(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
candidate, taste TagWeights
|
||||||
|
}{
|
||||||
|
{"no candidate tags", nil, TagWeights{"shoegaze": 5}},
|
||||||
|
{"no taste tags", TagWeights{"shoegaze": 1}, nil},
|
||||||
|
{"both empty", nil, nil},
|
||||||
|
{"taste weights all zero", TagWeights{"shoegaze": 1}, TagWeights{"shoegaze": 0}},
|
||||||
|
{"candidate weights all zero", TagWeights{"shoegaze": 0}, TagWeights{"shoegaze": 5}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, matched := tagOverlap(c.candidate, c.taste)
|
||||||
|
if got != 0 {
|
||||||
|
t.Errorf("%s: overlap = %v, want 0", c.name, got)
|
||||||
|
}
|
||||||
|
if matched != nil {
|
||||||
|
t.Errorf("%s: matched = %v, want nil", c.name, matched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A densely-tagged artist must not out-score a focused one merely by having
|
||||||
|
// more tags — that's what dividing by the candidate's own mass buys.
|
||||||
|
func TestTagOverlap_IsNotAPopularityContest(t *testing.T) {
|
||||||
|
taste := TagWeights{"shoegaze": 5.0}
|
||||||
|
focused, _ := tagOverlap(TagWeights{"shoegaze": 1.0}, taste)
|
||||||
|
sprawling, _ := tagOverlap(TagWeights{
|
||||||
|
"shoegaze": 1.0, "rock": 1.0, "alternative": 1.0, "90s": 1.0,
|
||||||
|
}, taste)
|
||||||
|
if !(focused > sprawling) {
|
||||||
|
t.Errorf("focused=%v sprawling=%v — extra unmatched tags must dilute, not add",
|
||||||
|
focused, sprawling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taste weights accumulate with listening, so raw magnitudes differ wildly
|
||||||
|
// between a new user and a heavy one while meaning the same thing. Normalizing
|
||||||
|
// by the user's own strongest tag is what makes the score comparable.
|
||||||
|
func TestTagOverlap_IsInvariantToTasteMagnitude(t *testing.T) {
|
||||||
|
candidate := TagWeights{"shoegaze": 1.0, "dream pop": 1.0}
|
||||||
|
newUser, _ := tagOverlap(candidate, TagWeights{"shoegaze": 2.0, "polka": 1.0})
|
||||||
|
heavyUser, _ := tagOverlap(candidate, TagWeights{"shoegaze": 2000.0, "polka": 1000.0})
|
||||||
|
if newUser != heavyUser {
|
||||||
|
t.Errorf("newUser=%v heavyUser=%v — scaling all taste weights must not change the result",
|
||||||
|
newUser, heavyUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTagOverlap_MatchedTagsAreOrderedAndCapped(t *testing.T) {
|
||||||
|
got, matched := tagOverlap(
|
||||||
|
TagWeights{"a": 0.2, "b": 0.9, "c": 0.5, "d": 0.7},
|
||||||
|
TagWeights{"a": 10, "b": 10, "c": 10, "d": 10},
|
||||||
|
)
|
||||||
|
if got <= 0 {
|
||||||
|
t.Fatalf("overlap = %v, want > 0", got)
|
||||||
|
}
|
||||||
|
// All taste weights equal, so candidate weight decides: b(.9) d(.7) c(.5).
|
||||||
|
want := []string{"b", "d", "c"}
|
||||||
|
if fmt.Sprint(matched) != fmt.Sprint(want) {
|
||||||
|
t.Errorf("matched = %v, want %v (strongest first, capped at %d)",
|
||||||
|
matched, want, maxMatchedTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- applyTagOverlap ---
|
||||||
|
|
||||||
|
func poolFor(specs ...struct {
|
||||||
|
mbid string
|
||||||
|
score float64
|
||||||
|
}) []ArtistSuggestion {
|
||||||
|
out := make([]ArtistSuggestion, 0, len(specs))
|
||||||
|
for _, s := range specs {
|
||||||
|
out = append(out, ArtistSuggestion{MBID: s.mbid, Name: s.mbid, Score: s.score})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
type spec = struct {
|
||||||
|
mbid string
|
||||||
|
score float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// The payoff, and the reason the fixture is skewed: a taste-matching candidate
|
||||||
|
// that started BELOW another must be able to overtake it. With an evenly
|
||||||
|
// matching pool this assertion could not fail.
|
||||||
|
func TestApplyTagOverlap_TasteMatchOvertakesAStrongerNonMatch(t *testing.T) {
|
||||||
|
pool := poolFor(spec{"loud", 1.0}, spec{"match", 0.7})
|
||||||
|
candTags := map[string]TagWeights{
|
||||||
|
"loud": {"death metal": 1.0},
|
||||||
|
"match": {"shoegaze": 1.0},
|
||||||
|
}
|
||||||
|
taste := TagWeights{"shoegaze": 5.0}
|
||||||
|
|
||||||
|
got := applyTagOverlap(pool, candTags, taste, 1.0)
|
||||||
|
if got[0].MBID != "match" {
|
||||||
|
t.Errorf("first = %q, want match (0.7×2 = 1.4 beats 1.0×1)", got[0].MBID)
|
||||||
|
}
|
||||||
|
if got[0].MatchedTags == nil {
|
||||||
|
t.Error("matched tags not stamped onto the winner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An untagged candidate keeps its score EXACTLY. This is the multiplicative
|
||||||
|
// blend's whole safety argument: partial coverage costs a candidate nothing.
|
||||||
|
func TestApplyTagOverlap_UntaggedCandidateScoreIsUnchanged(t *testing.T) {
|
||||||
|
pool := poolFor(spec{"untagged", 0.9})
|
||||||
|
got := applyTagOverlap(pool, map[string]TagWeights{}, TagWeights{"shoegaze": 5}, 1.0)
|
||||||
|
if got[0].Score != 0.9 {
|
||||||
|
t.Errorf("score = %v, want 0.9 exactly (no tags must not penalise)", got[0].Score)
|
||||||
|
}
|
||||||
|
if got[0].MatchedTags != nil {
|
||||||
|
t.Errorf("matched = %v, want nil", got[0].MatchedTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weight 0 is the operator's off switch: the ordering must be bit-for-bit the
|
||||||
|
// pre-slice-6 result, not merely similar.
|
||||||
|
func TestApplyTagOverlap_ZeroWeightIsAnExactNoOp(t *testing.T) {
|
||||||
|
pool := poolFor(spec{"a", 1.0}, spec{"b", 0.7})
|
||||||
|
candTags := map[string]TagWeights{"b": {"shoegaze": 1.0}}
|
||||||
|
got := applyTagOverlap(pool, candTags, TagWeights{"shoegaze": 5.0}, 0)
|
||||||
|
if got[0].MBID != "a" || got[0].Score != 1.0 || got[1].Score != 0.7 {
|
||||||
|
t.Errorf("weight 0 changed the pool: %+v", got)
|
||||||
|
}
|
||||||
|
// And it must not stamp matched tags either — the UI would otherwise
|
||||||
|
// explain a boost that never happened.
|
||||||
|
if got[1].MatchedTags != nil {
|
||||||
|
t.Errorf("matched tags stamped while the feature is off: %v", got[1].MatchedTags)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A user with no taste profile is the cold-start case: every candidate scores
|
||||||
|
// the same multiplier of 1, so similarity order must survive intact.
|
||||||
|
func TestApplyTagOverlap_NoTasteProfileLeavesOrderIntact(t *testing.T) {
|
||||||
|
pool := poolFor(spec{"a", 1.0}, spec{"b", 0.7}, spec{"c", 0.4})
|
||||||
|
candTags := map[string]TagWeights{"c": {"shoegaze": 1.0}}
|
||||||
|
got := applyTagOverlap(pool, candTags, nil, 1.0)
|
||||||
|
for i, want := range []string{"a", "b", "c"} {
|
||||||
|
if got[i].MBID != want {
|
||||||
|
t.Fatalf("order = %v..., want a b c", got[i].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded boost: even a perfect match at the maximum weight can only multiply
|
||||||
|
// by (1 + w), so a candidate cannot be catapulted arbitrarily far.
|
||||||
|
func TestApplyTagOverlap_BoostIsBoundedByOnePlusWeight(t *testing.T) {
|
||||||
|
pool := poolFor(spec{"perfect", 1.0})
|
||||||
|
got := applyTagOverlap(pool,
|
||||||
|
map[string]TagWeights{"perfect": {"shoegaze": 1.0}},
|
||||||
|
TagWeights{"shoegaze": 5.0}, 2.0)
|
||||||
|
if got[0].Score != 3.0 {
|
||||||
|
t.Errorf("score = %v, want 3.0 (1.0 × (1 + 2×1))", got[0].Score)
|
||||||
|
}
|
||||||
|
if got[0].TagOverlap != 1.0 {
|
||||||
|
t.Errorf("TagOverlap = %v, want 1.0", got[0].TagOverlap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal blended scores must resolve deterministically, or two candidates could
|
||||||
|
// swap between requests inside one day — the exact churn the daily rotation
|
||||||
|
// (#2373) exists to prevent.
|
||||||
|
func TestApplyTagOverlap_TiesBreakDeterministically(t *testing.T) {
|
||||||
|
first := applyTagOverlap(poolFor(spec{"zzz", 1.0}, spec{"aaa", 1.0}),
|
||||||
|
map[string]TagWeights{}, TagWeights{"x": 1}, 1.0)
|
||||||
|
second := applyTagOverlap(poolFor(spec{"aaa", 1.0}, spec{"zzz", 1.0}),
|
||||||
|
map[string]TagWeights{}, TagWeights{"x": 1}, 1.0)
|
||||||
|
if first[0].MBID != "aaa" || second[0].MBID != "aaa" {
|
||||||
|
t.Errorf("tie order not deterministic: %q then %q", first[0].MBID, second[0].MBID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,70 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
|
|||||||
return next, changes, nil
|
return next, changes, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discover tuning bounds.
|
||||||
|
const (
|
||||||
|
// A tag-overlap weight above this stops being a boost and becomes the
|
||||||
|
// ranking — at 10, a perfect match multiplies similarity by 11, which lets
|
||||||
|
// tag agreement swamp the similarity signal entirely. The bound is for
|
||||||
|
// typos, not to constrain exploration; the multiplicative blend keeps even
|
||||||
|
// the maximum from reordering an untagged candidate.
|
||||||
|
tagOverlapWeightMax = 10.0
|
||||||
|
// Snooze duration: at least a day (anything less isn't a snooze, it's a
|
||||||
|
// flicker), at most a year — past that it's a permanent dismissal wearing a
|
||||||
|
// snooze's clothes, which is exactly the shape rule #101 rules out.
|
||||||
|
snoozeDaysMin = 1.0
|
||||||
|
snoozeDaysMax = 365.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// applyDiscoverPatch validates and applies a partial Discover update.
|
||||||
|
func applyDiscoverPatch(
|
||||||
|
current DiscoverTuning, patch map[string]float64,
|
||||||
|
) (DiscoverTuning, []fieldChange, error) {
|
||||||
|
next := current
|
||||||
|
var changes []fieldChange
|
||||||
|
for field, v := range patch {
|
||||||
|
var target *float64
|
||||||
|
switch field {
|
||||||
|
case "tag_overlap_weight":
|
||||||
|
if v < 0 || v > tagOverlapWeightMax {
|
||||||
|
return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, %v])",
|
||||||
|
ErrOutOfRange, field, v, tagOverlapWeightMax)
|
||||||
|
}
|
||||||
|
target = &next.TagOverlapWeight
|
||||||
|
case "snooze_days":
|
||||||
|
if v < snoozeDaysMin || v > snoozeDaysMax {
|
||||||
|
return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
|
||||||
|
ErrOutOfRange, field, v, snoozeDaysMin, snoozeDaysMax)
|
||||||
|
}
|
||||||
|
target = &next.SnoozeDays
|
||||||
|
default:
|
||||||
|
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||||
|
}
|
||||||
|
if *target == v {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
|
||||||
|
*target = v
|
||||||
|
}
|
||||||
|
return next, changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffDiscover returns per-field changes from a to b (empty when equal).
|
||||||
|
func diffDiscover(a, b DiscoverTuning) []fieldChange {
|
||||||
|
var out []fieldChange
|
||||||
|
if a.TagOverlapWeight != b.TagOverlapWeight {
|
||||||
|
out = append(out, fieldChange{
|
||||||
|
Field: "tag_overlap_weight", Old: a.TagOverlapWeight, New: b.TagOverlapWeight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if a.SnoozeDays != b.SnoozeDays {
|
||||||
|
out = append(out, fieldChange{
|
||||||
|
Field: "snooze_days", Old: a.SnoozeDays, New: b.SnoozeDays,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// diffWeights returns per-field changes from a to b (empty when equal).
|
// diffWeights returns per-field changes from a to b (empty when equal).
|
||||||
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
|
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
|
||||||
var out []fieldChange
|
var out []fieldChange
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ const (
|
|||||||
ScopeRadio = "radio"
|
ScopeRadio = "radio"
|
||||||
ScopeDailyMix = "daily_mix"
|
ScopeDailyMix = "daily_mix"
|
||||||
ScopeTaste = "taste"
|
ScopeTaste = "taste"
|
||||||
|
// ScopeDiscover is the Discover request surface (#2377). Its own scope
|
||||||
|
// rather than columns on taste: SnoozeDays lives here, and a snooze must
|
||||||
|
// never be read as taste signal (#2374) — filing it under taste would put
|
||||||
|
// it one careless join from the leak that design forbids.
|
||||||
|
ScopeDiscover = "discover"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TasteTuning is the tunable subset of taste.Config: the engagement
|
// TasteTuning is the tunable subset of taste.Config: the engagement
|
||||||
@@ -87,6 +92,33 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiscoverTuning is the tunable set for the Discover request surface (#2377).
|
||||||
|
type DiscoverTuning struct {
|
||||||
|
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
|
||||||
|
// 0 disables it and restores pure similarity ranking.
|
||||||
|
TagOverlapWeight float64
|
||||||
|
// SnoozeDays is the default "not right now" duration (#2374).
|
||||||
|
SnoozeDays float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShippedDiscoverTuning are the shipped Discover defaults.
|
||||||
|
//
|
||||||
|
// TagOverlapWeight 1.0 lets a perfect tag match at most double a candidate's
|
||||||
|
// similarity score — enough to reorder the deck meaningfully, not enough for a
|
||||||
|
// popular-tag coincidence to beat a genuinely strong similarity match. It is a
|
||||||
|
// starting point for the tuning lab, not a tuned value: the honest way to pick
|
||||||
|
// it is the metrics trend view after some real use.
|
||||||
|
//
|
||||||
|
// SnoozeDays 90 matches the operator's approved shape: long enough that a
|
||||||
|
// parked suggestion stops nagging, short enough that a taste shift brings it
|
||||||
|
// back on its own.
|
||||||
|
func ShippedDiscoverTuning() DiscoverTuning {
|
||||||
|
return DiscoverTuning{
|
||||||
|
TagOverlapWeight: 1.0,
|
||||||
|
SnoozeDays: 90,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
|
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
|
||||||
func ShippedTasteTuning() TasteTuning {
|
func ShippedTasteTuning() TasteTuning {
|
||||||
d := taste.DefaultConfig()
|
d := taste.DefaultConfig()
|
||||||
@@ -110,6 +142,7 @@ type Service struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
profiles map[string]recommendation.ScoringWeights
|
profiles map[string]recommendation.ScoringWeights
|
||||||
taste TasteTuning
|
taste TasteTuning
|
||||||
|
discover DiscoverTuning
|
||||||
}
|
}
|
||||||
|
|
||||||
// New boots the service: seeds shipped defaults for missing rows,
|
// New boots the service: seeds shipped defaults for missing rows,
|
||||||
@@ -151,6 +184,13 @@ func (s *Service) reconcile(ctx context.Context) error {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("seed taste tuning: %w", err)
|
return fmt.Errorf("seed taste tuning: %w", err)
|
||||||
}
|
}
|
||||||
|
sd := ShippedDiscoverTuning()
|
||||||
|
if err := q.UpsertDiscoverTuningDefaults(ctx, dbq.UpsertDiscoverTuningDefaultsParams{
|
||||||
|
TagOverlapWeight: sd.TagOverlapWeight,
|
||||||
|
SnoozeDays: sd.SnoozeDays,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("seed discover tuning: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
rows, err := q.ListWeightProfiles(ctx)
|
rows, err := q.ListWeightProfiles(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -160,6 +200,10 @@ func (s *Service) reconcile(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get taste tuning: %w", err)
|
return fmt.Errorf("get taste tuning: %w", err)
|
||||||
}
|
}
|
||||||
|
dt, err := q.GetDiscoverTuning(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get discover tuning: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.profiles = map[string]recommendation.ScoringWeights{}
|
s.profiles = map[string]recommendation.ScoringWeights{}
|
||||||
@@ -175,6 +219,10 @@ func (s *Service) reconcile(ctx context.Context) error {
|
|||||||
EraScale: tt.EraScale,
|
EraScale: tt.EraScale,
|
||||||
MoodScale: tt.MoodScale,
|
MoodScale: tt.MoodScale,
|
||||||
}
|
}
|
||||||
|
s.discover = DiscoverTuning{
|
||||||
|
TagOverlapWeight: dt.TagOverlapWeight,
|
||||||
|
SnoozeDays: dt.SnoozeDays,
|
||||||
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
s.push()
|
s.push()
|
||||||
@@ -208,6 +256,15 @@ func (s *Service) Taste() TasteTuning {
|
|||||||
return s.taste
|
return s.taste
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discover returns the cached Discover-tuning values. Read per request by the
|
||||||
|
// suggestions handler, so an admin change takes effect on the next refresh
|
||||||
|
// with no restart (rule #25).
|
||||||
|
func (s *Service) Discover() DiscoverTuning {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.discover
|
||||||
|
}
|
||||||
|
|
||||||
// TasteConfig assembles the full taste.Config the profile builder
|
// TasteConfig assembles the full taste.Config the profile builder
|
||||||
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
|
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
|
||||||
// plus the tuned half-life and curve. WindowDays scales with the
|
// plus the tuned half-life and curve. WindowDays scales with the
|
||||||
@@ -267,6 +324,19 @@ func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) err
|
|||||||
return s.persistTaste(ctx, next, "update", changes)
|
return s.persistTaste(ctx, next, "update", changes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateDiscover applies a partial update to the Discover tuning singleton.
|
||||||
|
func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64) error {
|
||||||
|
current := s.Discover()
|
||||||
|
next, changes, err := applyDiscoverPatch(current, patch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.persistDiscover(ctx, next, "update", changes)
|
||||||
|
}
|
||||||
|
|
||||||
// Reset restores a scope to its shipped defaults, with one audit row
|
// Reset restores a scope to its shipped defaults, with one audit row
|
||||||
// carrying the full diff. A scope already at defaults is a no-op.
|
// carrying the full diff. A scope already at defaults is a no-op.
|
||||||
func (s *Service) Reset(ctx context.Context, scope string) error {
|
func (s *Service) Reset(ctx context.Context, scope string) error {
|
||||||
@@ -288,6 +358,13 @@ func (s *Service) Reset(ctx context.Context, scope string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return s.persistTaste(ctx, shipped, "reset", changes)
|
return s.persistTaste(ctx, shipped, "reset", changes)
|
||||||
|
case ScopeDiscover:
|
||||||
|
shipped := ShippedDiscoverTuning()
|
||||||
|
changes := diffDiscover(s.Discover(), shipped)
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.persistDiscover(ctx, shipped, "reset", changes)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
|
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
|
||||||
}
|
}
|
||||||
@@ -338,6 +415,28 @@ func (s *Service) persistTaste(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// persistDiscover writes the discover row + audit entry and refreshes the
|
||||||
|
// cache. No push(): unlike taste and daily_mix, nothing precomputes from these
|
||||||
|
// — the suggestions handler reads Discover() per request.
|
||||||
|
func (s *Service) persistDiscover(
|
||||||
|
ctx context.Context, d DiscoverTuning, action string, changes []fieldChange,
|
||||||
|
) error {
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
if _, err := q.UpdateDiscoverTuning(ctx, dbq.UpdateDiscoverTuningParams{
|
||||||
|
TagOverlapWeight: d.TagOverlapWeight,
|
||||||
|
SnoozeDays: d.SnoozeDays,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("update discover tuning: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.audit(ctx, q, ScopeDiscover, action, changes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.discover = d
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// audit writes one recommendation_tuning_audit row. Changes are
|
// audit writes one recommendation_tuning_audit row. Changes are
|
||||||
// sorted by field so rows are deterministic and diff-friendly.
|
// sorted by field so rows are deterministic and diff-friendly.
|
||||||
func (s *Service) audit(
|
func (s *Service) audit(
|
||||||
|
|||||||
@@ -281,3 +281,118 @@ func TestUpdate_NoOpWritesNoAudit(t *testing.T) {
|
|||||||
t.Errorf("no-op update wrote %d audit rows, want 0", len(rows))
|
t.Errorf("no-op update wrote %d audit rows, want 0", len(rows))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Discover scope (#2377, milestone #268 slice 6) ---
|
||||||
|
|
||||||
|
func TestNew_SeedsDiscoverDefaults(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
got := s.Discover()
|
||||||
|
want := ShippedDiscoverTuning()
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("Discover() = %+v, want shipped %+v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateDiscover_PersistsAndAudits(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
if err := s.UpdateDiscover(context.Background(), map[string]float64{
|
||||||
|
"tag_overlap_weight": 2.5,
|
||||||
|
"snooze_days": 30,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpdateDiscover: %v", err)
|
||||||
|
}
|
||||||
|
if got := s.Discover().TagOverlapWeight; got != 2.5 {
|
||||||
|
t.Errorf("TagOverlapWeight = %v, want 2.5", got)
|
||||||
|
}
|
||||||
|
if got := s.Discover().SnoozeDays; got != 30 {
|
||||||
|
t.Errorf("SnoozeDays = %v, want 30", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audit row must land under the new scope. This is the assertion that
|
||||||
|
// would have caught a missing rule-#36 CHECK migration: without expanding
|
||||||
|
// recommendation_tuning_audit's whitelist, this INSERT fails at runtime.
|
||||||
|
rows := auditRows(t, pool)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("audit rows = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Scope != ScopeDiscover {
|
||||||
|
t.Errorf("audit scope = %q, want %q", rows[0].Scope, ScopeDiscover)
|
||||||
|
}
|
||||||
|
if len(rows[0].Changes) != 2 {
|
||||||
|
t.Errorf("audit changes = %+v, want both fields", rows[0].Changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload from the DB to prove it persisted rather than only caching.
|
||||||
|
s2 := newService(t, pool)
|
||||||
|
if got := s2.Discover().TagOverlapWeight; got != 2.5 {
|
||||||
|
t.Errorf("after reload TagOverlapWeight = %v, want 2.5 (not re-seeded to shipped)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateDiscover_Validation(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
patch map[string]float64
|
||||||
|
}{
|
||||||
|
{"unknown field", map[string]float64{"nope": 1}},
|
||||||
|
{"negative weight", map[string]float64{"tag_overlap_weight": -1}},
|
||||||
|
{"weight past the typo bound", map[string]float64{"tag_overlap_weight": 100}},
|
||||||
|
// A sub-day snooze isn't a snooze, it's a flicker.
|
||||||
|
{"snooze under a day", map[string]float64{"snooze_days": 0.5}},
|
||||||
|
// Past a year it's a permanent dismissal wearing a snooze's clothes —
|
||||||
|
// the shape rule #101 rules out.
|
||||||
|
{"snooze past a year", map[string]float64{"snooze_days": 400}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := s.UpdateDiscover(context.Background(), c.patch); err == nil {
|
||||||
|
t.Errorf("%s: expected rejection, got nil", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := s.Discover(); got != ShippedDiscoverTuning() {
|
||||||
|
t.Errorf("a rejected patch mutated state: %+v", got)
|
||||||
|
}
|
||||||
|
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||||
|
t.Errorf("rejected patches wrote %d audit rows, want 0", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weight 0 must be accepted — it's the operator's off switch for the whole
|
||||||
|
// tag term, so a "must be positive" bound would remove their ability to
|
||||||
|
// disable the feature.
|
||||||
|
func TestUpdateDiscover_ZeroWeightIsAllowed(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
if err := s.UpdateDiscover(context.Background(),
|
||||||
|
map[string]float64{"tag_overlap_weight": 0}); err != nil {
|
||||||
|
t.Fatalf("UpdateDiscover(0): %v", err)
|
||||||
|
}
|
||||||
|
if got := s.Discover().TagOverlapWeight; got != 0 {
|
||||||
|
t.Errorf("TagOverlapWeight = %v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetDiscover_RestoresShippedDefaults(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
if err := s.UpdateDiscover(context.Background(),
|
||||||
|
map[string]float64{"tag_overlap_weight": 4}); err != nil {
|
||||||
|
t.Fatalf("UpdateDiscover: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Reset(context.Background(), ScopeDiscover); err != nil {
|
||||||
|
t.Fatalf("Reset: %v", err)
|
||||||
|
}
|
||||||
|
if got := s.Discover(); got != ShippedDiscoverTuning() {
|
||||||
|
t.Errorf("after reset = %+v, want shipped %+v", got, ShippedDiscoverTuning())
|
||||||
|
}
|
||||||
|
// Already-at-defaults is a no-op: update + reset = 2 rows, not 3.
|
||||||
|
if err := s.Reset(context.Background(), ScopeDiscover); err != nil {
|
||||||
|
t.Fatalf("second Reset: %v", err)
|
||||||
|
}
|
||||||
|
if rows := auditRows(t, pool); len(rows) != 2 {
|
||||||
|
t.Errorf("audit rows = %d, want 2 (the no-op reset must not audit)", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+211
-27
@@ -61,40 +61,25 @@ func NewEnricher(pool *pgxpool.Pool, logger *slog.Logger, settings *SettingsServ
|
|||||||
// enabled) the row settles to 'none'. If a provider fails transiently and
|
// enabled) the row settles to 'none'. If a provider fails transiently and
|
||||||
// no tags surfaced, the row is left NULL for a next-pass retry.
|
// no tags surfaced, the row is left NULL for a next-pass retry.
|
||||||
func (e *Enricher) EnrichTrack(ctx context.Context, trackID pgtype.UUID, ref TrackRef) (outcome, error) {
|
func (e *Enricher) EnrichTrack(ctx context.Context, trackID pgtype.UUID, ref TrackRef) (outcome, error) {
|
||||||
merged := map[string]float64{}
|
providers := e.settings.EnabledTrackTagProviders()
|
||||||
contributors := map[string]bool{}
|
calls := make([]tagFetch, 0, len(providers))
|
||||||
anyTransient := false
|
for _, provider := range providers {
|
||||||
|
calls = append(calls, tagFetch{
|
||||||
for _, provider := range e.settings.EnabledTrackTagProviders() {
|
providerID: provider.ID(),
|
||||||
tags, perr := provider.FetchTrackTags(ctx, ref)
|
fetch: func(c context.Context) ([]Tag, error) { return provider.FetchTrackTags(c, ref) },
|
||||||
switch {
|
})
|
||||||
case perr == nil:
|
|
||||||
for _, t := range tags {
|
|
||||||
if t.Weight > merged[t.Name] {
|
|
||||||
merged[t.Name] = t.Weight
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(tags) > 0 {
|
|
||||||
contributors[provider.ID()] = true
|
|
||||||
}
|
|
||||||
case errors.Is(perr, ErrNotFound):
|
|
||||||
// Clean "no data from this source" — try the next provider.
|
|
||||||
default:
|
|
||||||
anyTransient = true
|
|
||||||
e.logger.Warn("tags: provider fetch failed; continuing",
|
|
||||||
"track_id", uuidString(trackID), "provider", provider.ID(), "err", perr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
res := e.runChain(ctx, calls, "track_id", uuidString(trackID))
|
||||||
|
|
||||||
if len(merged) > 0 {
|
if len(res.merged) > 0 {
|
||||||
top := topKByWeight(merged, e.topK)
|
top := topKByWeight(res.merged, e.topK)
|
||||||
source := sourceLabel(contributorIDs(contributors))
|
source := sourceLabel(res.contributors)
|
||||||
if err := e.writeTags(ctx, trackID, top, source, e.settings.CurrentVersion()); err != nil {
|
if err := e.writeTags(ctx, trackID, top, source, e.settings.CurrentVersion()); err != nil {
|
||||||
return outcomeLeftNull, err
|
return outcomeLeftNull, err
|
||||||
}
|
}
|
||||||
return outcomeEnriched, nil
|
return outcomeEnriched, nil
|
||||||
}
|
}
|
||||||
if anyTransient {
|
if res.anyTransient {
|
||||||
// Nothing landed but a source may recover — leave NULL for retry.
|
// Nothing landed but a source may recover — leave NULL for retry.
|
||||||
return outcomeLeftNull, nil
|
return outcomeLeftNull, nil
|
||||||
}
|
}
|
||||||
@@ -106,6 +91,65 @@ func (e *Enricher) EnrichTrack(ctx context.Context, trackID pgtype.UUID, ref Tra
|
|||||||
return outcomeNone, nil
|
return outcomeNone, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tagFetch pairs a provider ID with a bound fetch call, so the merge-and-
|
||||||
|
// classify loop below is shared between the track chain and the candidate-
|
||||||
|
// artist chain (#2376) instead of being written twice with one word changed.
|
||||||
|
type tagFetch struct {
|
||||||
|
providerID string
|
||||||
|
fetch func(context.Context) ([]Tag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chainResult is what running a provider chain produced. contributors is the
|
||||||
|
// sorted set of provider IDs that actually returned tags — the input to
|
||||||
|
// sourceLabel.
|
||||||
|
type chainResult struct {
|
||||||
|
merged map[string]float64
|
||||||
|
contributors []string
|
||||||
|
anyTransient bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// runChain queries every provider in order and UNIONS the results (max weight
|
||||||
|
// wins on overlap), unlike coverart's first-success-wins.
|
||||||
|
//
|
||||||
|
// A clean ErrNotFound means "this source has nothing" and moves to the next.
|
||||||
|
// Anything else is transient and recorded, so the caller can leave the row
|
||||||
|
// eligible for a retry rather than wrongly settling it as "nothing exists" —
|
||||||
|
// the distinction between those two is the whole point of the return value.
|
||||||
|
//
|
||||||
|
// logKey/logVal identify the subject in warnings (a track id or a candidate
|
||||||
|
// MBID), since this is shared across entity types.
|
||||||
|
func (e *Enricher) runChain(ctx context.Context, calls []tagFetch, logKey, logVal string) chainResult {
|
||||||
|
merged := map[string]float64{}
|
||||||
|
contributors := map[string]bool{}
|
||||||
|
anyTransient := false
|
||||||
|
|
||||||
|
for _, c := range calls {
|
||||||
|
tags, perr := c.fetch(ctx)
|
||||||
|
switch {
|
||||||
|
case perr == nil:
|
||||||
|
for _, t := range tags {
|
||||||
|
if t.Weight > merged[t.Name] {
|
||||||
|
merged[t.Name] = t.Weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tags) > 0 {
|
||||||
|
contributors[c.providerID] = true
|
||||||
|
}
|
||||||
|
case errors.Is(perr, ErrNotFound):
|
||||||
|
// Clean "no data from this source" — try the next provider.
|
||||||
|
default:
|
||||||
|
anyTransient = true
|
||||||
|
e.logger.Warn("tags: provider fetch failed; continuing",
|
||||||
|
logKey, logVal, "provider", c.providerID, "err", perr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return chainResult{
|
||||||
|
merged: merged,
|
||||||
|
contributors: contributorIDs(contributors),
|
||||||
|
anyTransient: anyTransient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// writeTags atomically replaces a track's cached tags and stamps the source
|
// writeTags atomically replaces a track's cached tags and stamps the source
|
||||||
// + version. tags may be empty (the 'none' settle path), which just clears
|
// + version. tags may be empty (the 'none' settle path), which just clears
|
||||||
// any prior tags and records the outcome.
|
// any prior tags and records the outcome.
|
||||||
@@ -196,6 +240,146 @@ func (e *Enricher) EnrichTrackBatch(ctx context.Context, limit int,
|
|||||||
return processed, enriched, settledNone + leftNull + errored, nil
|
return processed, enriched, settledNone + leftNull + errored, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnrichCandidateArtist runs the artist-tag chain for one out-of-library
|
||||||
|
// Discover candidate and caches the merged result (#2376).
|
||||||
|
//
|
||||||
|
// Mirrors EnrichTrack, with one deliberate difference in the transient case:
|
||||||
|
// there is no row to "leave NULL", because eligibility is the ABSENCE of a
|
||||||
|
// candidate_artist_tag_state row. So a transient failure writes nothing at all,
|
||||||
|
// which leaves the candidate eligible for the next tick. Writing a state row
|
||||||
|
// here would settle a candidate whose tags we simply failed to fetch.
|
||||||
|
func (e *Enricher) EnrichCandidateArtist(ctx context.Context, mbid, name string) (outcome, error) {
|
||||||
|
providers := e.settings.EnabledArtistTagProviders()
|
||||||
|
ref := ArtistRef{MBID: mbid, Name: name}
|
||||||
|
calls := make([]tagFetch, 0, len(providers))
|
||||||
|
for _, provider := range providers {
|
||||||
|
calls = append(calls, tagFetch{
|
||||||
|
providerID: provider.ID(),
|
||||||
|
fetch: func(c context.Context) ([]Tag, error) { return provider.FetchArtistTags(c, ref) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
res := e.runChain(ctx, calls, "candidate_mbid", mbid)
|
||||||
|
version := e.settings.CurrentVersion()
|
||||||
|
|
||||||
|
if len(res.merged) > 0 {
|
||||||
|
top := topKByWeight(res.merged, e.topK)
|
||||||
|
if err := e.writeCandidateTags(ctx, mbid, top, sourceLabel(res.contributors), version); err != nil {
|
||||||
|
return outcomeLeftNull, err
|
||||||
|
}
|
||||||
|
return outcomeEnriched, nil
|
||||||
|
}
|
||||||
|
if res.anyTransient {
|
||||||
|
return outcomeLeftNull, nil
|
||||||
|
}
|
||||||
|
if err := e.writeCandidateTags(ctx, mbid, nil, sourceNone, version); err != nil {
|
||||||
|
return outcomeNone, err
|
||||||
|
}
|
||||||
|
return outcomeNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCandidateTags atomically replaces a candidate's cached tags and stamps
|
||||||
|
// its state. tags may be empty (the 'none' settle path), which clears any prior
|
||||||
|
// tags and records that the providers had nothing.
|
||||||
|
func (e *Enricher) writeCandidateTags(
|
||||||
|
ctx context.Context, mbid string, tags []Tag, source string, version int32,
|
||||||
|
) error {
|
||||||
|
tx, err := e.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
q := dbq.New(tx)
|
||||||
|
if err := q.DeleteCandidateArtistTags(ctx, mbid); err != nil {
|
||||||
|
return fmt.Errorf("delete candidate artist tags: %w", err)
|
||||||
|
}
|
||||||
|
for _, t := range tags {
|
||||||
|
if err := q.InsertCandidateArtistTag(ctx, dbq.InsertCandidateArtistTagParams{
|
||||||
|
CandidateMbid: mbid, Tag: t.Name, Weight: t.Weight,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("insert candidate artist tag: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := q.SetCandidateArtistTagState(ctx, dbq.SetCandidateArtistTagStateParams{
|
||||||
|
CandidateMbid: mbid,
|
||||||
|
TagSource: source,
|
||||||
|
TagSourcesVersion: version,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("set candidate artist tag state: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrichCandidateArtistBatch drains up to limit out-of-library candidates and
|
||||||
|
// enriches each serially. Same limit semantics as EnrichTrackBatch: 0 =
|
||||||
|
// disabled, >0 = bounded, <0 = unbounded.
|
||||||
|
//
|
||||||
|
// The candidate pool is far larger than the track pool (every library artist's
|
||||||
|
// neighbours) and can never be drained in one pass at MusicBrainz's ~1 req/s,
|
||||||
|
// so the query hands them back in descending similarity order — see
|
||||||
|
// ListCandidateArtistsMissingTags. A bounded batch here is therefore normal
|
||||||
|
// operation, not a degraded mode.
|
||||||
|
func (e *Enricher) EnrichCandidateArtistBatch(ctx context.Context, limit int) (
|
||||||
|
processed, succeeded, failed int, err error,
|
||||||
|
) {
|
||||||
|
if limit == 0 {
|
||||||
|
return 0, 0, 0, nil
|
||||||
|
}
|
||||||
|
queryLimit := int32(limit)
|
||||||
|
if limit < 0 {
|
||||||
|
queryLimit = 1<<31 - 1
|
||||||
|
}
|
||||||
|
q := dbq.New(e.pool)
|
||||||
|
rows, qerr := q.ListCandidateArtistsMissingTags(ctx, dbq.ListCandidateArtistsMissingTagsParams{
|
||||||
|
TagSourcesVersion: e.settings.CurrentVersion(),
|
||||||
|
Limit: queryLimit,
|
||||||
|
})
|
||||||
|
if qerr != nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("list candidate artists missing tags: %w", qerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var enriched, settledNone, leftNull, errored int
|
||||||
|
for _, r := range rows {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
e.logCandidateBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
|
||||||
|
return processed, enriched, settledNone + leftNull + errored, ctx.Err()
|
||||||
|
}
|
||||||
|
processed++
|
||||||
|
oc, eerr := e.EnrichCandidateArtist(ctx, r.CandidateMbid, r.CandidateName)
|
||||||
|
if eerr != nil {
|
||||||
|
e.logger.Warn("tags: candidate batch entry failed",
|
||||||
|
"candidate_mbid", r.CandidateMbid, "err", eerr)
|
||||||
|
errored++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch oc {
|
||||||
|
case outcomeEnriched:
|
||||||
|
enriched++
|
||||||
|
case outcomeNone:
|
||||||
|
settledNone++
|
||||||
|
case outcomeLeftNull:
|
||||||
|
leftNull++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.logCandidateBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
|
||||||
|
return processed, enriched, settledNone + leftNull + errored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logCandidateBatchSummary mirrors logBatchSummary. `settled_none` is the
|
||||||
|
// honest-ceiling counter for this surface: candidates whose MBID has no
|
||||||
|
// upstream tags at all, which no amount of retrying will fix.
|
||||||
|
func (e *Enricher) logCandidateBatchSummary(eligible, processed, enriched, settledNone, leftNull, errored int) {
|
||||||
|
e.logger.Info("tags: candidate-artist enrichment batch complete",
|
||||||
|
"eligible", eligible,
|
||||||
|
"processed", processed,
|
||||||
|
"enriched", enriched,
|
||||||
|
"settled_none", settledNone,
|
||||||
|
"left_null", leftNull,
|
||||||
|
"errored", errored)
|
||||||
|
}
|
||||||
|
|
||||||
// logBatchSummary emits one Info line with the category breakdown — the
|
// logBatchSummary emits one Info line with the category breakdown — the
|
||||||
// enriched/settled/left-null split is the operator's diagnostic for a
|
// enriched/settled/left-null split is the operator's diagnostic for a
|
||||||
// "0 enriched" symptom the collapsed tally can't explain.
|
// "0 enriched" symptom the collapsed tally can't explain.
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package tags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runChain is the merge-and-classify loop shared by the track and
|
||||||
|
// candidate-artist drains (#2376). It touches no DB, so it is testable with a
|
||||||
|
// bare Enricher.
|
||||||
|
//
|
||||||
|
// The classification is the part that matters: "every source cleanly had
|
||||||
|
// nothing" and "a source failed" lead to opposite persistence decisions
|
||||||
|
// (settle vs. leave eligible for retry), and conflating them either writes off
|
||||||
|
// an artist over a transient blip or re-fetches a genuinely untagged one
|
||||||
|
// forever.
|
||||||
|
|
||||||
|
func chainEnricher() *Enricher {
|
||||||
|
return &Enricher{logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fixedFetch(tags []Tag, err error) func(context.Context) ([]Tag, error) {
|
||||||
|
return func(context.Context) ([]Tag, error) { return tags, err }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunChain_UnionsAcrossProvidersMaxWeightWins(t *testing.T) {
|
||||||
|
res := chainEnricher().runChain(context.Background(), []tagFetch{
|
||||||
|
{providerID: "musicbrainz", fetch: fixedFetch([]Tag{
|
||||||
|
{Name: "shoegaze", Weight: 0.4},
|
||||||
|
{Name: "noise", Weight: 0.9},
|
||||||
|
}, nil)},
|
||||||
|
{providerID: "lastfm", fetch: fixedFetch([]Tag{
|
||||||
|
{Name: "shoegaze", Weight: 0.8}, // higher — should win
|
||||||
|
{Name: "dream pop", Weight: 0.3},
|
||||||
|
}, nil)},
|
||||||
|
}, "subject", "x")
|
||||||
|
|
||||||
|
if got := res.merged["shoegaze"]; got != 0.8 {
|
||||||
|
t.Errorf("shoegaze = %v, want 0.8 (max across providers)", got)
|
||||||
|
}
|
||||||
|
if got := res.merged["noise"]; got != 0.9 {
|
||||||
|
t.Errorf("noise = %v, want 0.9", got)
|
||||||
|
}
|
||||||
|
if got := res.merged["dream pop"]; got != 0.3 {
|
||||||
|
t.Errorf("dream pop = %v, want 0.3", got)
|
||||||
|
}
|
||||||
|
if len(res.merged) != 3 {
|
||||||
|
t.Errorf("merged has %d tags, want 3: %v", len(res.merged), res.merged)
|
||||||
|
}
|
||||||
|
if res.anyTransient {
|
||||||
|
t.Error("anyTransient set with no failures")
|
||||||
|
}
|
||||||
|
if len(res.contributors) != 2 {
|
||||||
|
t.Errorf("contributors = %v, want both providers", res.contributors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lower weight arriving second must not overwrite a higher one — the
|
||||||
|
// ordering of the chain must not change the result.
|
||||||
|
func TestRunChain_LowerWeightSecondDoesNotClobber(t *testing.T) {
|
||||||
|
res := chainEnricher().runChain(context.Background(), []tagFetch{
|
||||||
|
{providerID: "a", fetch: fixedFetch([]Tag{{Name: "rock", Weight: 0.9}}, nil)},
|
||||||
|
{providerID: "b", fetch: fixedFetch([]Tag{{Name: "rock", Weight: 0.2}}, nil)},
|
||||||
|
}, "subject", "x")
|
||||||
|
if got := res.merged["rock"]; got != 0.9 {
|
||||||
|
t.Errorf("rock = %v, want 0.9", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunChain_NotFoundIsSkippedNotTransient(t *testing.T) {
|
||||||
|
res := chainEnricher().runChain(context.Background(), []tagFetch{
|
||||||
|
{providerID: "a", fetch: fixedFetch(nil, ErrNotFound)},
|
||||||
|
{providerID: "b", fetch: fixedFetch([]Tag{{Name: "folk", Weight: 1}}, nil)},
|
||||||
|
}, "subject", "x")
|
||||||
|
|
||||||
|
if res.anyTransient {
|
||||||
|
t.Error("ErrNotFound must not be treated as transient — it would keep a settled subject eligible forever")
|
||||||
|
}
|
||||||
|
if len(res.contributors) != 1 || res.contributors[0] != "b" {
|
||||||
|
t.Errorf("contributors = %v, want [b] only (a returned nothing)", res.contributors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunChain_TransientIsRecordedEvenWhenAnotherProviderSucceeds(t *testing.T) {
|
||||||
|
res := chainEnricher().runChain(context.Background(), []tagFetch{
|
||||||
|
{providerID: "a", fetch: fixedFetch(nil, ErrTransient)},
|
||||||
|
{providerID: "b", fetch: fixedFetch([]Tag{{Name: "folk", Weight: 1}}, nil)},
|
||||||
|
}, "subject", "x")
|
||||||
|
|
||||||
|
if !res.anyTransient {
|
||||||
|
t.Error("anyTransient should be set — 'a' may have had tags we never saw")
|
||||||
|
}
|
||||||
|
// Tags DID land, so the caller writes them; anyTransient only decides the
|
||||||
|
// no-tags case. Asserting both here pins that they're independent.
|
||||||
|
if len(res.merged) != 1 {
|
||||||
|
t.Errorf("merged = %v, want folk", res.merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An unexpected error type is transient, not terminal. Defaulting the other way
|
||||||
|
// would settle a subject on any bug in a provider.
|
||||||
|
func TestRunChain_UnknownErrorIsTransient(t *testing.T) {
|
||||||
|
res := chainEnricher().runChain(context.Background(), []tagFetch{
|
||||||
|
{providerID: "a", fetch: fixedFetch(nil, errors.New("boom"))},
|
||||||
|
}, "subject", "x")
|
||||||
|
if !res.anyTransient {
|
||||||
|
t.Error("unknown error should count as transient")
|
||||||
|
}
|
||||||
|
if len(res.merged) != 0 {
|
||||||
|
t.Errorf("merged = %v, want empty", res.merged)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A provider returning (empty, nil) is not a contributor: sourceLabel would
|
||||||
|
// otherwise stamp its ID onto a subject it gave nothing to.
|
||||||
|
func TestRunChain_EmptySuccessIsNotAContributor(t *testing.T) {
|
||||||
|
res := chainEnricher().runChain(context.Background(), []tagFetch{
|
||||||
|
{providerID: "a", fetch: fixedFetch(nil, nil)},
|
||||||
|
{providerID: "b", fetch: fixedFetch([]Tag{{Name: "folk", Weight: 1}}, nil)},
|
||||||
|
}, "subject", "x")
|
||||||
|
if len(res.contributors) != 1 || res.contributors[0] != "b" {
|
||||||
|
t.Errorf("contributors = %v, want [b]", res.contributors)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No enabled providers is a clean "nothing found", NOT a failure — the caller
|
||||||
|
// settles the subject rather than retrying an empty chain on every tick.
|
||||||
|
func TestRunChain_EmptyChainSettlesRatherThanRetries(t *testing.T) {
|
||||||
|
res := chainEnricher().runChain(context.Background(), nil, "subject", "x")
|
||||||
|
if res.anyTransient {
|
||||||
|
t.Error("an empty chain must not look transient")
|
||||||
|
}
|
||||||
|
if len(res.merged) != 0 || len(res.contributors) != 0 {
|
||||||
|
t.Errorf("empty chain produced %v / %v", res.merged, res.contributors)
|
||||||
|
}
|
||||||
|
if sourceLabel(res.contributors) != sourceNone {
|
||||||
|
t.Errorf("sourceLabel = %q, want %q", sourceLabel(res.contributors), sourceNone)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -87,6 +87,32 @@ type TrackTagProvider interface {
|
|||||||
FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error)
|
FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArtistRef is the lookup key for artist-level tags. MBID is the artist's
|
||||||
|
// MusicBrainz ID (required by MBID-keyed providers); Name is the fallback for
|
||||||
|
// name-based providers (Last.fm). At least one must be set or every provider
|
||||||
|
// returns ErrNotFound.
|
||||||
|
type ArtistRef struct {
|
||||||
|
MBID string
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistTagProvider is the artist-level tag capability, added for tag-space
|
||||||
|
// Discover (#2376). Separate from TrackTagProvider — exactly the split that
|
||||||
|
// interface's doc comment anticipated — so a source can implement either
|
||||||
|
// without the other, and so the enricher can ask for the capability it needs
|
||||||
|
// rather than checking at the call site.
|
||||||
|
//
|
||||||
|
// The subject here is an artist Minstrel does NOT have locally, so there is no
|
||||||
|
// track to fall back to and no recording-level tag to prefer.
|
||||||
|
type ArtistTagProvider interface {
|
||||||
|
Provider
|
||||||
|
// FetchArtistTags returns the artist's folksonomy tags, or ErrNotFound
|
||||||
|
// (terminal — nothing upstream) / ErrTransient (retry). A disabled
|
||||||
|
// provider, or one missing a required key, returns ErrNotFound so the
|
||||||
|
// chain simply skips it.
|
||||||
|
FetchArtistTags(ctx context.Context, ref ArtistRef) ([]Tag, error)
|
||||||
|
}
|
||||||
|
|
||||||
// TestableProvider is an opt-in capability for the admin Test-Connection
|
// TestableProvider is an opt-in capability for the admin Test-Connection
|
||||||
// button: answer "is my config working?" without a full enrichment cycle.
|
// button: answer "is my config working?" without a full enrichment cycle.
|
||||||
type TestableProvider interface {
|
type TestableProvider interface {
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package tags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Artist-level tag fetching for out-of-library Discover candidates (#2376).
|
||||||
|
// Reuses the mbEntityServer / newMBProvider / newLastfmProvider helpers from
|
||||||
|
// the per-provider test files.
|
||||||
|
|
||||||
|
func TestMusicBrainzFetchArtistTags_UsesFullWeightNotTheFallbackDiscount(t *testing.T) {
|
||||||
|
// THE distinction worth a test. artistTagWeightFactor (0.6) exists because
|
||||||
|
// FetchTrackTags uses artist tags as a *proxy* for a track's tags. Here the
|
||||||
|
// artist IS the subject, so weights must land unscaled — otherwise these
|
||||||
|
// are not comparable with track_tags, which is the exact comparison slice 6
|
||||||
|
// is built on.
|
||||||
|
srv := mbEntityServer(``, `{"tags":[{"count":4,"name":"shoegaze"},{"count":2,"name":"dream pop"}]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
old := mbBaseURL
|
||||||
|
mbBaseURL = srv.URL
|
||||||
|
defer func() { mbBaseURL = old }()
|
||||||
|
|
||||||
|
tags, err := newMBProvider(true).FetchArtistTags(context.Background(),
|
||||||
|
ArtistRef{MBID: "art-1", Name: "Some Band"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch: %v", err)
|
||||||
|
}
|
||||||
|
m := tagsByName(tags)
|
||||||
|
if got := m["shoegaze"]; got != 1.0 {
|
||||||
|
t.Errorf("shoegaze = %v, want 1.0 — not discounted by artistTagWeightFactor", got)
|
||||||
|
}
|
||||||
|
if got := m["dream pop"]; got != 0.5 {
|
||||||
|
t.Errorf("dream pop = %v, want 0.5", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMusicBrainzFetchArtistTags_GatedOff(t *testing.T) {
|
||||||
|
if _, err := newMBProvider(false).FetchArtistTags(context.Background(),
|
||||||
|
ArtistRef{MBID: "art-1"}); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("disabled: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
// No MBID → nothing MusicBrainz can look up. A name-based guess could
|
||||||
|
// silently attach the wrong artist's tags, so it deliberately doesn't try.
|
||||||
|
if _, err := newMBProvider(true).FetchArtistTags(context.Background(),
|
||||||
|
ArtistRef{Name: "Some Band"}); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("no MBID: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchEntityTags reports an existing-but-untagged entity as (empty, nil) so
|
||||||
|
// FetchTrackTags can fall through to the artist level. FetchArtistTags has no
|
||||||
|
// next level, so it must convert that to the terminal ErrNotFound — otherwise
|
||||||
|
// the enricher would read "no error" as success and settle the candidate as
|
||||||
|
// enriched with zero tags.
|
||||||
|
func TestMusicBrainzFetchArtistTags_UntaggedArtistIsNotFound(t *testing.T) {
|
||||||
|
srv := mbEntityServer(``, `{"tags":[]}`)
|
||||||
|
defer srv.Close()
|
||||||
|
old := mbBaseURL
|
||||||
|
mbBaseURL = srv.URL
|
||||||
|
defer func() { mbBaseURL = old }()
|
||||||
|
|
||||||
|
_, err := newMBProvider(true).FetchArtistTags(context.Background(), ArtistRef{MBID: "art-1"})
|
||||||
|
if !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastfmFetchArtistTags_CallsArtistGetTopTags(t *testing.T) {
|
||||||
|
var gotMethod, gotArtist, gotMBID string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotMethod = r.URL.Query().Get("method")
|
||||||
|
gotArtist = r.URL.Query().Get("artist")
|
||||||
|
gotMBID = r.URL.Query().Get("mbid")
|
||||||
|
_, _ = w.Write([]byte(`{"toptags":{"tag":[{"name":"post-punk","count":100},{"name":"moody","count":40}]}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
old := lastfmBaseURL
|
||||||
|
lastfmBaseURL = srv.URL + "/"
|
||||||
|
defer func() { lastfmBaseURL = old }()
|
||||||
|
|
||||||
|
p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
|
||||||
|
_ = p.Configure(ProviderSettings{Enabled: true, APIKey: "k"})
|
||||||
|
|
||||||
|
tags, err := p.FetchArtistTags(context.Background(),
|
||||||
|
ArtistRef{MBID: "art-1", Name: "Some Band"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch: %v", err)
|
||||||
|
}
|
||||||
|
if gotMethod != "artist.gettoptags" {
|
||||||
|
t.Errorf("method = %q, want artist.gettoptags", gotMethod)
|
||||||
|
}
|
||||||
|
if gotArtist != "Some Band" {
|
||||||
|
t.Errorf("artist = %q, want Some Band", gotArtist)
|
||||||
|
}
|
||||||
|
// MBID is sent as a disambiguating hint when we have one.
|
||||||
|
if gotMBID != "art-1" {
|
||||||
|
t.Errorf("mbid = %q, want art-1", gotMBID)
|
||||||
|
}
|
||||||
|
m := tagsByName(tags)
|
||||||
|
if m["post-punk"] != 1.0 {
|
||||||
|
t.Errorf("post-punk = %v, want 1.0", m["post-punk"])
|
||||||
|
}
|
||||||
|
if m["moody"] != 0.4 {
|
||||||
|
t.Errorf("moody = %v, want 0.4", m["moody"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastfmFetchArtistTags_GatedOff(t *testing.T) {
|
||||||
|
unkeyed := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
|
||||||
|
_ = unkeyed.Configure(ProviderSettings{Enabled: true})
|
||||||
|
if _, err := unkeyed.FetchArtistTags(context.Background(),
|
||||||
|
ArtistRef{Name: "A"}); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("unkeyed: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyed := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
|
||||||
|
_ = keyed.Configure(ProviderSettings{Enabled: false, APIKey: "k"})
|
||||||
|
if _, err := keyed.FetchArtistTags(context.Background(),
|
||||||
|
ArtistRef{Name: "A"}); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("disabled: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name-based provider with no name → nothing to query. This is the case
|
||||||
|
// that matters in practice: a candidate whose name coalesced to '' still
|
||||||
|
// has an MBID, so MusicBrainz can serve it while Last.fm cannot.
|
||||||
|
enabled := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
|
||||||
|
_ = enabled.Configure(ProviderSettings{Enabled: true, APIKey: "k"})
|
||||||
|
if _, err := enabled.FetchArtistTags(context.Background(),
|
||||||
|
ArtistRef{MBID: "art-1"}); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("no name: err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastfmFetchArtistTags_TransientErrorCodeRetries(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"error":29}`)) // rate limited
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
old := lastfmBaseURL
|
||||||
|
lastfmBaseURL = srv.URL + "/"
|
||||||
|
defer func() { lastfmBaseURL = old }()
|
||||||
|
|
||||||
|
p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
|
||||||
|
_ = p.Configure(ProviderSettings{Enabled: true, APIKey: "k"})
|
||||||
|
_, err := p.FetchArtistTags(context.Background(), ArtistRef{Name: "A"})
|
||||||
|
// Must be ErrTransient, not ErrNotFound: the enricher settles a candidate
|
||||||
|
// to 'none' on ErrNotFound, which would permanently write off an artist we
|
||||||
|
// were merely throttled on.
|
||||||
|
if !errors.Is(err, ErrTransient) {
|
||||||
|
t.Errorf("err = %v, want ErrTransient", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastfmFetchArtistTags_UnknownArtistIsNotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"error":6}`)) // "not found" — terminal
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
old := lastfmBaseURL
|
||||||
|
lastfmBaseURL = srv.URL + "/"
|
||||||
|
defer func() { lastfmBaseURL = old }()
|
||||||
|
|
||||||
|
p := &lastfmProvider{client: newHTTPClient(httpClientOptions{Name: "lastfm", MaxRetries: 1})}
|
||||||
|
_ = p.Configure(ProviderSettings{Enabled: true, APIKey: "k"})
|
||||||
|
if _, err := p.FetchArtistTags(context.Background(),
|
||||||
|
ArtistRef{Name: "Nobody"}); !errors.Is(err, ErrNotFound) {
|
||||||
|
t.Errorf("err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -121,6 +121,50 @@ func (p *lastfmProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Ta
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchArtistTags looks up an artist's top tags by name, with the MBID as an
|
||||||
|
// extra hint when present (#2376). Name-first is deliberate and the opposite
|
||||||
|
// emphasis from MusicBrainz: Last.fm's tag data is keyed on its own artist
|
||||||
|
// pages, and autocorrect resolves most spelling drift from the similarity feed.
|
||||||
|
//
|
||||||
|
// `artist.getTopTags` returns the same `toptags` envelope as
|
||||||
|
// `track.getTopTags`, so the response type and normalizer are reused as-is —
|
||||||
|
// the 0-100 popularity scale is identical.
|
||||||
|
func (p *lastfmProvider) FetchArtistTags(ctx context.Context, ref ArtistRef) ([]Tag, error) {
|
||||||
|
if !p.enabled.Load() || p.currentKey() == "" {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if ref.Name == "" {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
q := url.Values{
|
||||||
|
"method": {"artist.gettoptags"},
|
||||||
|
"api_key": {p.currentKey()},
|
||||||
|
"format": {"json"},
|
||||||
|
"artist": {ref.Name},
|
||||||
|
"autocorrect": {"1"},
|
||||||
|
}
|
||||||
|
if ref.MBID != "" {
|
||||||
|
q.Set("mbid", ref.MBID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp lastfmTopTags
|
||||||
|
if err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Error != 0 {
|
||||||
|
if lastfmTransientErrors[resp.Error] {
|
||||||
|
return nil, ErrTransient
|
||||||
|
}
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
out := normalizeLastfmTags(resp.TopTags.Tag)
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// TestConnection verifies the key against a well-known track.
|
// TestConnection verifies the key against a well-known track.
|
||||||
func (p *lastfmProvider) TestConnection(ctx context.Context) error {
|
func (p *lastfmProvider) TestConnection(ctx context.Context) error {
|
||||||
if p.currentKey() == "" {
|
if p.currentKey() == "" {
|
||||||
@@ -163,6 +207,7 @@ func normalizeLastfmTags(raw []lastfmTag) []Tag {
|
|||||||
|
|
||||||
// Compile-time capability checks.
|
// Compile-time capability checks.
|
||||||
var (
|
var (
|
||||||
_ TrackTagProvider = (*lastfmProvider)(nil)
|
_ TrackTagProvider = (*lastfmProvider)(nil)
|
||||||
_ TestableProvider = (*lastfmProvider)(nil)
|
_ ArtistTagProvider = (*lastfmProvider)(nil)
|
||||||
|
_ TestableProvider = (*lastfmProvider)(nil)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -96,6 +96,33 @@ func (p *musicbrainzProvider) FetchTrackTags(ctx context.Context, ref TrackRef)
|
|||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FetchArtistTags looks up an artist's own tags by MBID (#2376). MBID-only:
|
||||||
|
// MusicBrainz has no name-based tag lookup worth trusting for this, and a
|
||||||
|
// wrong-artist match would poison the tag cache silently.
|
||||||
|
//
|
||||||
|
// Note the scale is 1.0, NOT artistTagWeightFactor. That discount exists
|
||||||
|
// because FetchTrackTags uses artist tags as a *proxy* for a track's tags, and
|
||||||
|
// the artist's overall character is the coarser signal of the two. Here the
|
||||||
|
// artist IS the subject, so there is nothing to discount relative to — and
|
||||||
|
// applying it would make these weights incomparable with track_tags, which is
|
||||||
|
// exactly the comparison slice 6 depends on.
|
||||||
|
func (p *musicbrainzProvider) FetchArtistTags(ctx context.Context, ref ArtistRef) ([]Tag, error) {
|
||||||
|
if !p.enabled.Load() || ref.MBID == "" {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
tags, err := p.fetchEntityTags(ctx, "artist", ref.MBID, 1.0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// fetchEntityTags reports an existing-but-untagged entity as (empty, nil)
|
||||||
|
// so FetchTrackTags can fall through to the next level. There is no next
|
||||||
|
// level here, so empty is the terminal "nothing upstream".
|
||||||
|
if len(tags) == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return tags, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fetchEntityTags loads folksonomy tags for a MusicBrainz entity ("recording"
|
// fetchEntityTags loads folksonomy tags for a MusicBrainz entity ("recording"
|
||||||
// or "artist") by MBID and scales the normalized weights by `scale`. Returns
|
// or "artist") by MBID and scales the normalized weights by `scale`. Returns
|
||||||
// an empty slice (not ErrNotFound) when the entity exists but is untagged, so
|
// an empty slice (not ErrNotFound) when the entity exists but is untagged, so
|
||||||
@@ -153,6 +180,7 @@ func normalizeMBTags(raw []mbTag) []Tag {
|
|||||||
|
|
||||||
// Compile-time capability checks.
|
// Compile-time capability checks.
|
||||||
var (
|
var (
|
||||||
_ TrackTagProvider = (*musicbrainzProvider)(nil)
|
_ TrackTagProvider = (*musicbrainzProvider)(nil)
|
||||||
_ TestableProvider = (*musicbrainzProvider)(nil)
|
_ ArtistTagProvider = (*musicbrainzProvider)(nil)
|
||||||
|
_ TestableProvider = (*musicbrainzProvider)(nil)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -135,6 +135,27 @@ func (s *SettingsService) EnabledTrackTagProviders() []TrackTagProvider {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnabledArtistTagProviders returns the enabled providers implementing
|
||||||
|
// ArtistTagProvider, in registration order. Snapshot — do not mutate.
|
||||||
|
//
|
||||||
|
// Separate from EnabledTrackTagProviders rather than one call with a capability
|
||||||
|
// argument: the two chains are consumed by different drains, and a provider may
|
||||||
|
// implement one capability without the other.
|
||||||
|
func (s *SettingsService) EnabledArtistTagProviders() []ArtistTagProvider {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
var out []ArtistTagProvider
|
||||||
|
for _, p := range AllProviders() {
|
||||||
|
if !s.enabledIDs[p.ID()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ap, ok := p.(ArtistTagProvider); ok {
|
||||||
|
out = append(out, ap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// CurrentVersion returns the version the enricher stamps onto rows.
|
// CurrentVersion returns the version the enricher stamps onto rows.
|
||||||
func (s *SettingsService) CurrentVersion() int32 {
|
func (s *SettingsService) CurrentVersion() int32 {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
|
|||||||
+35
-10
@@ -14,22 +14,32 @@ import (
|
|||||||
// providers' httpClients, so a tick just drains a bounded batch and the
|
// providers' httpClients, so a tick just drains a bounded batch and the
|
||||||
// external APIs pace themselves.
|
// external APIs pace themselves.
|
||||||
type Worker struct {
|
type Worker struct {
|
||||||
enricher *Enricher
|
enricher *Enricher
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
tick time.Duration
|
tick time.Duration
|
||||||
batch int
|
batch int
|
||||||
|
candidateBatch int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWorker constructs a worker with production defaults: an initial drain
|
// NewWorker constructs a worker with production defaults: an initial drain
|
||||||
// shortly after boot, then every 30 minutes, up to 200 tracks per tick.
|
// shortly after boot, then every 30 minutes, up to 200 tracks per tick.
|
||||||
// MusicBrainz's 1 req/s ceiling is the real throttle, so the batch size
|
// MusicBrainz's 1 req/s ceiling is the real throttle, so the batch size
|
||||||
// mainly bounds how long one tick runs, not the request rate.
|
// mainly bounds how long one tick runs, not the request rate.
|
||||||
|
//
|
||||||
|
// candidateBatch is smaller than the track batch on purpose. Library tracks are
|
||||||
|
// a finite set that drains to completion and then costs nothing; out-of-library
|
||||||
|
// candidates (#2376) are effectively unbounded — every library artist's
|
||||||
|
// neighbours — so this arm would otherwise monopolise every tick forever and
|
||||||
|
// starve the track arm. 50/tick at ~1 req/s is roughly a minute of work, and
|
||||||
|
// the query hands back the highest-similarity candidates first so the ones that
|
||||||
|
// can actually reach a user's deck are enriched first.
|
||||||
func NewWorker(enricher *Enricher, logger *slog.Logger) *Worker {
|
func NewWorker(enricher *Enricher, logger *slog.Logger) *Worker {
|
||||||
return &Worker{
|
return &Worker{
|
||||||
enricher: enricher,
|
enricher: enricher,
|
||||||
logger: logger,
|
logger: logger,
|
||||||
tick: 30 * time.Minute,
|
tick: 30 * time.Minute,
|
||||||
batch: 200,
|
batch: 200,
|
||||||
|
candidateBatch: 50,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,12 +58,27 @@ func (w *Worker) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// tickOnce drains one bounded batch. EnrichTrackBatch already logs a
|
// tickOnce drains one bounded batch of each kind. Both Enrich*Batch methods
|
||||||
// category breakdown, so this only surfaces a fatal batch error.
|
// already log a category breakdown, so this only surfaces a fatal batch error.
|
||||||
|
//
|
||||||
|
// Tracks first: they back the taste profile the whole app reads from, whereas
|
||||||
|
// candidate tags only affect the Discover request surface. On a fresh install
|
||||||
|
// both are cold, and getting the profile warm matters more.
|
||||||
func (w *Worker) tickOnce(ctx context.Context) {
|
func (w *Worker) tickOnce(ctx context.Context) {
|
||||||
if _, _, _, err := w.enricher.EnrichTrackBatch(ctx, w.batch, nil); err != nil {
|
if _, _, _, err := w.enricher.EnrichTrackBatch(ctx, w.batch, nil); err != nil {
|
||||||
if ctx.Err() == nil {
|
if ctx.Err() == nil {
|
||||||
w.logger.Error("tags: enrichment tick failed", "err", err)
|
w.logger.Error("tags: enrichment tick failed", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Not gated on the track arm's success: the two drains share nothing but a
|
||||||
|
// provider chain, and a track-side failure says nothing about whether
|
||||||
|
// candidate lookups will work.
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, _, _, err := w.enricher.EnrichCandidateArtistBatch(ctx, w.candidateBatch); err != nil {
|
||||||
|
if ctx.Err() == nil {
|
||||||
|
w.logger.Error("tags: candidate-artist enrichment tick failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ export const qk = {
|
|||||||
smtpConfig: () => ['smtpConfig'] as const,
|
smtpConfig: () => ['smtpConfig'] as const,
|
||||||
suggestions: (limit?: number) =>
|
suggestions: (limit?: number) =>
|
||||||
['suggestions', { limit: limit ?? 12 }] as const,
|
['suggestions', { limit: limit ?? 12 }] as const,
|
||||||
|
suggestionSnoozes: () => ['suggestionSnoozes'] as const,
|
||||||
home: () => ['home'] as const,
|
home: () => ['home'] as const,
|
||||||
albumsAlpha: () => ['albumsAlpha'] as const,
|
albumsAlpha: () => ['albumsAlpha'] as const,
|
||||||
artistTracks: (artistId: string) => ['artistTracks', artistId] as const,
|
artistTracks: (artistId: string) => ['artistTracks', artistId] as const,
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('./client', () => ({
|
vi.mock('./client', () => ({
|
||||||
api: { get: vi.fn() }
|
api: { get: vi.fn(), post: vi.fn(), del: vi.fn() }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { listSuggestions } from './suggestions';
|
import {
|
||||||
|
listSuggestions,
|
||||||
|
listSnoozes,
|
||||||
|
snoozeSuggestion,
|
||||||
|
unsnoozeSuggestion
|
||||||
|
} from './suggestions';
|
||||||
import { qk } from './queries';
|
import { qk } from './queries';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import type { ArtistSuggestion } from './types';
|
import type { ArtistSuggestion, SuggestionSnooze } from './types';
|
||||||
|
|
||||||
afterEach(() => vi.clearAllMocks());
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
@@ -40,3 +45,56 @@ describe('suggestions client', () => {
|
|||||||
expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]);
|
expect(qk.suggestions(20)).toEqual(['suggestions', { limit: 20 }]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('suggestion snoozes (#2375)', () => {
|
||||||
|
test('snoozeSuggestion sends the name — the server 400s without it', async () => {
|
||||||
|
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||||
|
await snoozeSuggestion('mb-1', 'Parked Artist');
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/api/discover/suggestions/mb-1/snooze', {
|
||||||
|
name: 'Parked Artist'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snoozeSuggestion sends no days, leaving the default to the server', async () => {
|
||||||
|
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||||
|
await snoozeSuggestion('mb-1', 'Parked Artist');
|
||||||
|
const body = (api.post as ReturnType<typeof vi.fn>).mock.calls[0][1] as Record<string, unknown>;
|
||||||
|
expect(body).not.toHaveProperty('days');
|
||||||
|
});
|
||||||
|
|
||||||
|
// MBIDs are UUIDs today, but the column is free-text and the value comes
|
||||||
|
// from an external similarity feed, so it goes through encodeURIComponent.
|
||||||
|
test('the mbid is URL-encoded into the path', async () => {
|
||||||
|
(api.post as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||||
|
await snoozeSuggestion('weird/id?x', 'Odd');
|
||||||
|
expect(api.post).toHaveBeenCalledWith(
|
||||||
|
'/api/discover/suggestions/weird%2Fid%3Fx/snooze',
|
||||||
|
{ name: 'Odd' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unsnoozeSuggestion DELETEs the same path', async () => {
|
||||||
|
(api.del as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||||
|
await unsnoozeSuggestion('mb-1');
|
||||||
|
expect(api.del).toHaveBeenCalledWith('/api/discover/suggestions/mb-1/snooze');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listSnoozes hits the snoozes collection', async () => {
|
||||||
|
const fixture: SuggestionSnooze[] = [
|
||||||
|
{
|
||||||
|
mbid: 'mb-1',
|
||||||
|
name: 'Parked Artist',
|
||||||
|
snoozed_until: '2026-11-01T00:00:00Z',
|
||||||
|
created_at: '2026-08-03T00:00:00Z'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture);
|
||||||
|
const got = await listSnoozes();
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/discover/snoozes');
|
||||||
|
expect(got).toEqual(fixture);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('qk.suggestionSnoozes key shape', () => {
|
||||||
|
expect(qk.suggestionSnoozes()).toEqual(['suggestionSnoozes']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createQuery } from '@tanstack/svelte-query';
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
import { qk } from './queries';
|
import { qk } from './queries';
|
||||||
import type { ArtistSuggestion } from './types';
|
import type { ArtistSuggestion, SuggestionSnooze } from './types';
|
||||||
|
|
||||||
export async function listSuggestions(limit = 12): Promise<ArtistSuggestion[]> {
|
export async function listSuggestions(limit = 12): Promise<ArtistSuggestion[]> {
|
||||||
return api.get<ArtistSuggestion[]>(`/api/discover/suggestions?limit=${limit}`);
|
return api.get<ArtistSuggestion[]>(`/api/discover/suggestions?limit=${limit}`);
|
||||||
@@ -14,3 +14,36 @@ export function createSuggestionsQuery(limit = 12) {
|
|||||||
staleTime: 5 * 60_000 // 5 minutes — see M5c spec §5
|
staleTime: 5 * 60_000 // 5 minutes — see M5c spec §5
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parks a suggestion for the server's default period (90 days). `name` is
|
||||||
|
// REQUIRED by the server and is not optional bookkeeping: candidates 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` is sent. There is deliberately no duration UI yet — that knob is
|
||||||
|
// slice 6 (#2377) — and hardcoding a value here would pin the default to the
|
||||||
|
// client instead of the server that owns it.
|
||||||
|
export async function snoozeSuggestion(mbid: string, name: string): Promise<void> {
|
||||||
|
await api.post<null>(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`, { name });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brings a parked suggestion back immediately. The server 404s an MBID that
|
||||||
|
// was never snoozed; callers treat that as already-unsnoozed rather than as a
|
||||||
|
// failure, since the end state the user asked for is the one they get.
|
||||||
|
export async function unsnoozeSuggestion(mbid: string): Promise<void> {
|
||||||
|
await api.del(`/api/discover/suggestions/${encodeURIComponent(mbid)}/snooze`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSnoozes(): Promise<SuggestionSnooze[]> {
|
||||||
|
return api.get<SuggestionSnooze[]>('/api/discover/snoozes');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSnoozesQuery() {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: qk.suggestionSnoozes(),
|
||||||
|
queryFn: listSnoozes
|
||||||
|
// No staleTime, unlike the suggestions query: this list is the only route
|
||||||
|
// back to an un-snooze, so it must reflect a snooze made seconds ago
|
||||||
|
// rather than a cached view of the world.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,14 +26,24 @@ export type TasteTuning = {
|
|||||||
mood_scale: number;
|
mood_scale: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
|
// Discover request-surface knobs (#2377). Its own scope rather than part of
|
||||||
|
// taste: snooze_days belongs here, and a snooze must never be read as taste
|
||||||
|
// signal (#2374).
|
||||||
|
export type DiscoverTuning = {
|
||||||
|
tag_overlap_weight: number;
|
||||||
|
snooze_days: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TuningScope = 'radio' | 'daily_mix' | 'taste' | 'discover';
|
||||||
|
|
||||||
export type TuningSnapshot = {
|
export type TuningSnapshot = {
|
||||||
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
|
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
|
||||||
taste: TasteTuning;
|
taste: TasteTuning;
|
||||||
|
discover: DiscoverTuning;
|
||||||
shipped: {
|
shipped: {
|
||||||
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
|
profiles: Record<'radio' | 'daily_mix', WeightProfile>;
|
||||||
taste: TasteTuning;
|
taste: TasteTuning;
|
||||||
|
discover: DiscoverTuning;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,20 @@ export type ArtistSuggestion = {
|
|||||||
score: number;
|
score: number;
|
||||||
attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC
|
attribution: SeedContribution[]; // up to 3 entries, ordered by contribution DESC
|
||||||
image_url?: string; // resolved on-demand from Lidarr; absent → card placeholder
|
image_url?: string; // resolved on-demand from Lidarr; absent → card placeholder
|
||||||
|
// Tags this candidate shares with your taste profile, strongest first (max 3,
|
||||||
|
// #2377). Absent for most candidates — tag coverage for artists you don't own
|
||||||
|
// is partial by nature (#2376) — so the card falls back to seed attribution.
|
||||||
|
matched_tags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// One parked suggestion — "not right now", not a dislike. The server only
|
||||||
|
// ever returns rows whose snoozed_until is still in the future, so the client
|
||||||
|
// never has to compare against the clock to decide what to show.
|
||||||
|
export type SuggestionSnooze = {
|
||||||
|
mbid: string;
|
||||||
|
name: string;
|
||||||
|
snoozed_until: string; // RFC3339
|
||||||
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mirrors internal/api/types.go HomePayload. All slices are non-null
|
// Mirrors internal/api/types.go HomePayload. All slices are non-null
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
export type DiscoverCardKind = 'artist' | 'album' | 'track';
|
export type DiscoverCardKind = 'artist' | 'album' | 'track';
|
||||||
export type DiscoverCardState = 'requestable' | 'kept' | 'requested';
|
// 'snoozed' is a transient state the card flips to in place after the user
|
||||||
|
// parks it, so the disappearance is legible and undoable rather than a card
|
||||||
|
// silently vanishing from under the cursor (rule #24). The row is gone on
|
||||||
|
// the next refetch; the persistent way back is the snoozed list.
|
||||||
|
export type DiscoverCardState = 'requestable' | 'kept' | 'requested' | 'snoozed';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Plus, Disc3, Album, Music2 } from 'lucide-svelte';
|
import { Plus, Disc3, Album, Music2, Clock } from 'lucide-svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
kind,
|
kind,
|
||||||
@@ -14,6 +18,8 @@
|
|||||||
state,
|
state,
|
||||||
attribution,
|
attribution,
|
||||||
onRequest,
|
onRequest,
|
||||||
|
onSnooze,
|
||||||
|
onUnsnooze,
|
||||||
}: {
|
}: {
|
||||||
kind: DiscoverCardKind;
|
kind: DiscoverCardKind;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -22,6 +28,10 @@
|
|||||||
state: DiscoverCardState;
|
state: DiscoverCardState;
|
||||||
attribution?: string;
|
attribution?: string;
|
||||||
onRequest?: () => void;
|
onRequest?: () => void;
|
||||||
|
// Omit both to get a card with no snooze affordance — the Lidarr search
|
||||||
|
// results reuse this component and have nothing to park.
|
||||||
|
onSnooze?: () => void;
|
||||||
|
onUnsnooze?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
const FallbackIcon = $derived(
|
const FallbackIcon = $derived(
|
||||||
@@ -65,20 +75,50 @@
|
|||||||
<div class="badge-row" data-testid="badge-row">
|
<div class="badge-row" data-testid="badge-row">
|
||||||
{#if state === 'kept'}
|
{#if state === 'kept'}
|
||||||
<span class="kept-pill" role="status">Kept</span>
|
<span class="kept-pill" role="status">Kept</span>
|
||||||
|
{:else if state === 'snoozed'}
|
||||||
|
<span class="snoozed-pill" role="status">Not right now</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions pt-3" data-testid="actions">
|
<div class="actions pt-3" data-testid="actions">
|
||||||
{#if state === 'requestable'}
|
{#if state === 'snoozed'}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Request ${title}`}
|
aria-label={`Bring ${title} back`}
|
||||||
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
|
class="rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
onclick={handleRequest}
|
onclick={() => onUnsnooze?.()}
|
||||||
>
|
>
|
||||||
<Plus size={16} strokeWidth={1} /> Request
|
Undo
|
||||||
</button>
|
</button>
|
||||||
|
{:else if state === 'requestable'}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Request ${title}`}
|
||||||
|
class="flex items-center gap-1 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg"
|
||||||
|
onclick={handleRequest}
|
||||||
|
>
|
||||||
|
<Plus size={16} strokeWidth={1} /> Request
|
||||||
|
</button>
|
||||||
|
{#if onSnooze}
|
||||||
|
<!--
|
||||||
|
Icon-only to keep Request unambiguously the primary action, with
|
||||||
|
the intent carried by the accessible name. "Not right now" is the
|
||||||
|
whole point of the wording: this parks a suggestion, it does not
|
||||||
|
record an opinion about the artist (rule #101).
|
||||||
|
-->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Not right now — hide ${title} for a while`}
|
||||||
|
title="Not right now"
|
||||||
|
class="rounded-md border border-border p-1.5 text-text-secondary hover:bg-surface-hover hover:text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
onclick={() => onSnooze?.()}
|
||||||
|
>
|
||||||
|
<Clock size={16} strokeWidth={1} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else if state === 'kept'}
|
{:else if state === 'kept'}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -131,4 +171,20 @@
|
|||||||
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 15%, transparent);
|
||||||
color: var(--fs-accent);
|
color: var(--fs-accent);
|
||||||
}
|
}
|
||||||
|
/* Muted rather than accented: a parked card should recede, not compete
|
||||||
|
with the live suggestions around it. Same geometry as .kept-pill so the
|
||||||
|
two read as one component in different states. */
|
||||||
|
.snoozed-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 14px;
|
||||||
|
background: var(--fs-slate);
|
||||||
|
color: var(--fs-vellum);
|
||||||
|
}
|
||||||
|
.card[data-state='snoozed'] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -125,4 +125,47 @@ describe('DiscoverResultCard', () => {
|
|||||||
});
|
});
|
||||||
expect(screen.queryByTestId('attribution')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('attribution')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- snooze affordance (#2375) ---
|
||||||
|
|
||||||
|
test('snooze button appears only when onSnooze is supplied', () => {
|
||||||
|
// The Lidarr search results reuse this card and have nothing to park, so
|
||||||
|
// the affordance must not appear unconditionally.
|
||||||
|
render(DiscoverResultCard, {
|
||||||
|
props: { kind: 'artist', title: 'Outsider', state: 'requestable' }
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('button', { name: /not right now/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snooze button calls onSnooze and reads as "not right now", never as a dislike', async () => {
|
||||||
|
const onSnooze = vi.fn();
|
||||||
|
render(DiscoverResultCard, {
|
||||||
|
props: { kind: 'artist', title: 'Outsider', state: 'requestable', onSnooze }
|
||||||
|
});
|
||||||
|
const btn = screen.getByRole('button', { name: /not right now — hide outsider for a while/i });
|
||||||
|
// Rule #101: the accessible name must carry no verdict on the music.
|
||||||
|
expect(btn.getAttribute('aria-label')).not.toMatch(/dislike|not for me|never|hate/i);
|
||||||
|
await fireEvent.click(btn);
|
||||||
|
expect(onSnooze).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snoozed state swaps Request for Undo and shows a status pill', async () => {
|
||||||
|
const onUnsnooze = vi.fn();
|
||||||
|
const onRequest = vi.fn();
|
||||||
|
render(DiscoverResultCard, {
|
||||||
|
props: {
|
||||||
|
kind: 'artist',
|
||||||
|
title: 'Outsider',
|
||||||
|
state: 'snoozed',
|
||||||
|
onRequest,
|
||||||
|
onUnsnooze
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole('button', { name: /request outsider/i })).not.toBeInTheDocument();
|
||||||
|
const status = screen.getByRole('status');
|
||||||
|
expect(status.textContent).toMatch(/not right now/i);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /bring outsider back/i }));
|
||||||
|
expect(onUnsnooze).toHaveBeenCalledOnce();
|
||||||
|
expect(onRequest).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,5 +35,9 @@
|
|||||||
transition-transform duration-200
|
transition-transform duration-200
|
||||||
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
|
{player.queueDrawerOpen ? 'translate-x-0' : 'translate-x-full'}"
|
||||||
>
|
>
|
||||||
<QueueList onClose={() => closeQueueDrawer()} bind:closeButtonRef={closeButton} />
|
<QueueList
|
||||||
|
onClose={() => closeQueueDrawer()}
|
||||||
|
active={player.queueDrawerOpen}
|
||||||
|
bind:closeButtonRef={closeButton}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ vi.mock('$lib/player/store.svelte', () => ({
|
|||||||
get queueDrawerOpen() { return openValue; }
|
get queueDrawerOpen() { return openValue; }
|
||||||
},
|
},
|
||||||
// QueueTrackRow imports these from the store; provide stubs so its
|
// QueueTrackRow imports these from the store; provide stubs so its
|
||||||
// module-load doesn't break when QueueDrawer renders rows.
|
// module-load doesn't break when QueueDrawer renders rows. QueueList
|
||||||
|
// imports clearQueue for its header action.
|
||||||
playFromQueueIndex: vi.fn(),
|
playFromQueueIndex: vi.fn(),
|
||||||
removeFromQueue: vi.fn(),
|
removeFromQueue: vi.fn(),
|
||||||
moveQueueItem: vi.fn()
|
moveQueueItem: vi.fn(),
|
||||||
|
clearQueue: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import QueueDrawer from './QueueDrawer.svelte';
|
import QueueDrawer from './QueueDrawer.svelte';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X } from 'lucide-svelte';
|
import { untrack } from 'svelte';
|
||||||
import { player } from '$lib/player/store.svelte';
|
import { X, Trash2, ArrowDown } from 'lucide-svelte';
|
||||||
|
import { player, clearQueue } from '$lib/player/store.svelte';
|
||||||
import QueueTrackRow from './QueueTrackRow.svelte';
|
import QueueTrackRow from './QueueTrackRow.svelte';
|
||||||
|
|
||||||
// onClose: when provided, renders an X button in the header so the
|
// onClose: when provided, renders an X button in the header so the
|
||||||
@@ -8,12 +9,66 @@
|
|||||||
// now-playing route (visible at lg+ widths) omits it.
|
// now-playing route (visible at lg+ widths) omits it.
|
||||||
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
// closeButtonRef: bind:this hook so the drawer can focus the X for
|
||||||
// keyboard users on open.
|
// keyboard users on open.
|
||||||
|
// active: true when the queue is on-screen (drawer open, or the always-
|
||||||
|
// visible now-playing panel). Gates the scroll-to-current behavior.
|
||||||
type Props = {
|
type Props = {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
closeButtonRef?: HTMLButtonElement;
|
closeButtonRef?: HTMLButtonElement;
|
||||||
|
active?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { onClose, closeButtonRef = $bindable() }: Props = $props();
|
let { onClose, closeButtonRef = $bindable(), active = true }: Props = $props();
|
||||||
|
|
||||||
|
let scrollBody: HTMLElement | undefined = $state();
|
||||||
|
// Whether the now-playing row is (at least partly) within the scroll
|
||||||
|
// viewport. Drives auto-follow (only follow while the user is watching the
|
||||||
|
// current track) and the "Jump to current" pill (shown when it's off-screen).
|
||||||
|
let currentInView = $state(true);
|
||||||
|
let sawFirstIndex = false;
|
||||||
|
|
||||||
|
function scrollToCurrent(block: ScrollLogicalPosition, behavior: ScrollBehavior = 'auto') {
|
||||||
|
(scrollBody?.children[player.index] as HTMLElement | undefined)?.scrollIntoView({
|
||||||
|
block,
|
||||||
|
behavior,
|
||||||
|
});
|
||||||
|
currentInView = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recomputeInView() {
|
||||||
|
const row = scrollBody?.children[player.index] as HTMLElement | undefined;
|
||||||
|
if (!scrollBody || !row) {
|
||||||
|
currentInView = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const b = scrollBody.getBoundingClientRect();
|
||||||
|
const r = row.getBoundingClientRect();
|
||||||
|
currentInView = r.bottom > b.top && r.top < b.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On open (active flips true, or on mount for the always-visible panel),
|
||||||
|
// center the now-playing row — parity with the Android queue.
|
||||||
|
$effect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
if (untrack(() => player.queue.length) === 0) return;
|
||||||
|
requestAnimationFrame(() => scrollToCurrent('center'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Follow the current track as it auto-advances, but only while the user is
|
||||||
|
// still watching it — if they've scrolled away, leave them there (the pill
|
||||||
|
// offers the way back). block:'nearest' keeps it minimal (no yank when the
|
||||||
|
// row is already visible). Index is tracked; currentInView is read untracked
|
||||||
|
// so a scroll that hides the row doesn't itself re-trigger a scroll.
|
||||||
|
$effect(() => {
|
||||||
|
player.index; // subscribe: follow on advance
|
||||||
|
if (!sawFirstIndex) {
|
||||||
|
sawFirstIndex = true;
|
||||||
|
return; // the open effect already handled the initial position
|
||||||
|
}
|
||||||
|
if (!active) return;
|
||||||
|
if (untrack(() => player.queue.length) === 0) return;
|
||||||
|
if (!untrack(() => currentInView)) return;
|
||||||
|
requestAnimationFrame(() => scrollToCurrent('nearest'));
|
||||||
|
});
|
||||||
|
|
||||||
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
function totalDurationLabel(tracks: { duration_sec: number }[]): string {
|
||||||
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
const totalSec = tracks.reduce((s, tr) => s + (tr.duration_sec ?? 0), 0);
|
||||||
@@ -23,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col">
|
<div class="relative flex h-full flex-col">
|
||||||
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
<div class="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-lg font-semibold">Queue</h2>
|
<h2 class="text-lg font-semibold">Queue</h2>
|
||||||
@@ -32,20 +87,33 @@
|
|||||||
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
{#if player.queue.length > 0} · {totalDurationLabel(player.queue)}{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{#if onClose}
|
<div class="flex items-center gap-1">
|
||||||
<button
|
{#if player.queue.length > 0}
|
||||||
type="button"
|
<button
|
||||||
bind:this={closeButtonRef}
|
type="button"
|
||||||
aria-label="Close queue"
|
aria-label="Clear queue"
|
||||||
onclick={onClose}
|
title="Clear queue"
|
||||||
class="text-text-secondary hover:text-text-primary"
|
onclick={() => clearQueue()}
|
||||||
>
|
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
||||||
<X size={20} />
|
>
|
||||||
</button>
|
<Trash2 size={18} />
|
||||||
{/if}
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if onClose}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
bind:this={closeButtonRef}
|
||||||
|
aria-label="Close queue"
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded p-1 text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div bind:this={scrollBody} onscroll={recomputeInView} class="flex-1 overflow-y-auto">
|
||||||
{#if player.queue.length === 0}
|
{#if player.queue.length === 0}
|
||||||
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
<p class="text-text-secondary text-center p-8">No tracks queued.</p>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -54,4 +122,17 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if active && player.queue.length > 0 && !currentInView}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => scrollToCurrent('center', 'smooth')}
|
||||||
|
class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1.5
|
||||||
|
rounded-full bg-action-secondary px-3 py-1.5 text-xs font-medium
|
||||||
|
text-action-fg shadow-lg"
|
||||||
|
>
|
||||||
|
<ArrowDown size={14} />
|
||||||
|
Jump to current
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { draggable, type DragEventData } from '@neodrag/svelte';
|
import { draggable, type DragEventData } from '@neodrag/svelte';
|
||||||
import type { TrackRef } from '$lib/api/types';
|
import type { TrackRef } from '$lib/api/types';
|
||||||
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
|
import { playFromQueueIndex, removeFromQueue, moveQueueItem } from '$lib/player/store.svelte';
|
||||||
|
import { coverUrl, FALLBACK_COVER } from '$lib/media/covers';
|
||||||
import { offsetToDelta } from './queue-row-math';
|
import { offsetToDelta } from './queue-row-math';
|
||||||
import LikeButton from './LikeButton.svelte';
|
import LikeButton from './LikeButton.svelte';
|
||||||
|
|
||||||
@@ -66,6 +67,13 @@
|
|||||||
<GripVertical size={16} />
|
<GripVertical size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={coverUrl(track.album_id)}
|
||||||
|
alt=""
|
||||||
|
onerror={(e) => ((e.currentTarget as HTMLImageElement).src = FALLBACK_COVER)}
|
||||||
|
class="h-10 w-10 flex-shrink-0 rounded object-cover"
|
||||||
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={handleBodyClick}
|
onclick={handleBodyClick}
|
||||||
|
|||||||
@@ -1,23 +1,63 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQueryClient } from '@tanstack/svelte-query';
|
import { useQueryClient } from '@tanstack/svelte-query';
|
||||||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
import {
|
||||||
|
createSuggestionsQuery,
|
||||||
|
createSnoozesQuery,
|
||||||
|
snoozeSuggestion,
|
||||||
|
unsnoozeSuggestion
|
||||||
|
} from '$lib/api/suggestions';
|
||||||
import { createRequest } from '$lib/api/requests';
|
import { createRequest } from '$lib/api/requests';
|
||||||
import { qk } from '$lib/api/queries';
|
import { qk } from '$lib/api/queries';
|
||||||
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
import DiscoverResultCard from './DiscoverResultCard.svelte';
|
import DiscoverResultCard from './DiscoverResultCard.svelte';
|
||||||
import type { ArtistSuggestion, SeedContribution } from '$lib/api/types';
|
import type { ArtistSuggestion, SeedContribution, SuggestionSnooze } from '$lib/api/types';
|
||||||
|
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
const queryStore = createSuggestionsQuery();
|
const queryStore = createSuggestionsQuery();
|
||||||
const query = $derived($queryStore);
|
const query = $derived($queryStore);
|
||||||
const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]);
|
const suggestions = $derived((query.data ?? []) as ArtistSuggestion[]);
|
||||||
|
|
||||||
|
const snoozeStore = createSnoozesQuery();
|
||||||
|
const snoozeQuery = $derived($snoozeStore);
|
||||||
|
const snoozes = $derived((snoozeQuery.data ?? []) as SuggestionSnooze[]);
|
||||||
|
|
||||||
// Track MBIDs the user just requested so the card flips immediately.
|
// Track MBIDs the user just requested so the card flips immediately.
|
||||||
let optimisticRequested = $state(new Set<string>());
|
let optimisticRequested = $state(new Set<string>());
|
||||||
|
// Snoozed-just-now MBIDs. These keep their card in place showing an Undo,
|
||||||
|
// rather than yanking it out of the grid under the cursor — the card is
|
||||||
|
// gone on the next refetch, and the snoozed list below is the way back
|
||||||
|
// after that.
|
||||||
|
let optimisticSnoozed = $state(new Set<string>());
|
||||||
|
|
||||||
function visible(s: ArtistSuggestion): boolean {
|
function visible(s: ArtistSuggestion): boolean {
|
||||||
return !optimisticRequested.has(s.mbid);
|
return !optimisticRequested.has(s.mbid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cardState(s: ArtistSuggestion): 'requestable' | 'snoozed' {
|
||||||
|
return optimisticSnoozed.has(s.mbid) ? 'snoozed' : 'requestable';
|
||||||
|
}
|
||||||
|
|
||||||
|
function withMbid(set: Set<string>, mbid: string, present: boolean): Set<string> {
|
||||||
|
const next = new Set(set);
|
||||||
|
if (present) next.add(mbid);
|
||||||
|
else next.delete(mbid);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Matches your taste in shoegaze and dream pop." — the reason line when the
|
||||||
|
// candidate's own tags overlap the taste profile (#2377). Preferred over seed
|
||||||
|
// attribution because it describes the MUSIC ("sounds like what you like")
|
||||||
|
// rather than the graph ("adjacent to something you played"), which is the
|
||||||
|
// whole point of slice 6. Falls back when there are no matched tags, which is
|
||||||
|
// the common case: coverage is partial by nature (#2376).
|
||||||
|
function reasonText(s: ArtistSuggestion): string {
|
||||||
|
const tags = s.matched_tags ?? [];
|
||||||
|
if (tags.length === 0) return attributionText(s.attribution);
|
||||||
|
if (tags.length === 1) return `Matches your taste in ${tags[0]}.`;
|
||||||
|
if (tags.length === 2) return `Matches your taste in ${tags[0]} and ${tags[1]}.`;
|
||||||
|
return `Matches your taste in ${tags[0]}, ${tags[1]}, and ${tags[2]}.`;
|
||||||
|
}
|
||||||
|
|
||||||
function attributionText(attribution: SeedContribution[]): string {
|
function attributionText(attribution: SeedContribution[]): string {
|
||||||
if (attribution.length === 0) return '';
|
if (attribution.length === 0) return '';
|
||||||
const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played');
|
const verb = (s: SeedContribution) => (s.is_liked ? 'liked' : 'played');
|
||||||
@@ -32,6 +72,24 @@
|
|||||||
return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`;
|
return `Because you ${phrases[0]}, ${phrases[1]}, and ${phrases[2]}.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "in 3 months" / "in 12 days" — a relative phrase, because the exact
|
||||||
|
// calendar date of a 90-day snooze is noise the user never asked for.
|
||||||
|
function returnsIn(snoozedUntil: string): string {
|
||||||
|
const ms = new Date(snoozedUntil).getTime() - Date.now();
|
||||||
|
if (!Number.isFinite(ms) || ms <= 0) return 'shortly';
|
||||||
|
const days = Math.round(ms / 86_400_000);
|
||||||
|
if (days < 1) return 'today';
|
||||||
|
if (days === 1) return 'tomorrow';
|
||||||
|
// The 30 must not exceed the divisor below, or the singular "in about a
|
||||||
|
// month" is unreachable — a rounded month count of 1 needs 15..44 days,
|
||||||
|
// and any higher threshold sends all of those down the days branch. This
|
||||||
|
// read 45 and the singular case was dead code (caught by the Android
|
||||||
|
// unit test for the same logic).
|
||||||
|
if (days < 30) return `in ${days} days`;
|
||||||
|
const months = Math.round(days / 30);
|
||||||
|
return months === 1 ? 'in about a month' : `in about ${months} months`;
|
||||||
|
}
|
||||||
|
|
||||||
async function onRequest(s: ArtistSuggestion) {
|
async function onRequest(s: ArtistSuggestion) {
|
||||||
try {
|
try {
|
||||||
await createRequest({
|
await createRequest({
|
||||||
@@ -39,9 +97,7 @@
|
|||||||
lidarr_artist_mbid: s.mbid,
|
lidarr_artist_mbid: s.mbid,
|
||||||
artist_name: s.name
|
artist_name: s.name
|
||||||
});
|
});
|
||||||
const next = new Set(optimisticRequested);
|
optimisticRequested = withMbid(optimisticRequested, s.mbid, true);
|
||||||
next.add(s.mbid);
|
|
||||||
optimisticRequested = next;
|
|
||||||
// The server-side filter hides this candidate on next refetch.
|
// The server-side filter hides this candidate on next refetch.
|
||||||
await client.invalidateQueries({ queryKey: qk.suggestions() });
|
await client.invalidateQueries({ queryKey: qk.suggestions() });
|
||||||
} catch {
|
} catch {
|
||||||
@@ -49,6 +105,39 @@
|
|||||||
// stays requestable so the user can retry.
|
// stays requestable so the user can retry.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onSnooze(s: ArtistSuggestion) {
|
||||||
|
// Flip first so the tap feels instant, then reconcile. On failure the
|
||||||
|
// card goes back to requestable and says so — a snooze that silently
|
||||||
|
// did nothing would leave the user tapping it again.
|
||||||
|
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, true);
|
||||||
|
try {
|
||||||
|
await snoozeSuggestion(s.mbid, s.name);
|
||||||
|
await client.invalidateQueries({ queryKey: qk.suggestionSnoozes() });
|
||||||
|
} catch {
|
||||||
|
optimisticSnoozed = withMbid(optimisticSnoozed, s.mbid, false);
|
||||||
|
pushToast(`Couldn't hide ${s.name}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUnsnooze(mbid: string, name: string) {
|
||||||
|
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, false);
|
||||||
|
try {
|
||||||
|
await unsnoozeSuggestion(mbid);
|
||||||
|
} catch (e) {
|
||||||
|
// 404 means it wasn't snoozed after all — the user's intended end
|
||||||
|
// state, so it isn't an error worth showing them.
|
||||||
|
if ((e as { status?: number })?.status !== 404) {
|
||||||
|
optimisticSnoozed = withMbid(optimisticSnoozed, mbid, true);
|
||||||
|
pushToast(`Couldn't bring ${name} back`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.all([
|
||||||
|
client.invalidateQueries({ queryKey: qk.suggestionSnoozes() }),
|
||||||
|
client.invalidateQueries({ queryKey: qk.suggestions() })
|
||||||
|
]);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -58,7 +147,16 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if !query.isPending && suggestions.length === 0}
|
{#if !query.isPending && suggestions.length === 0}
|
||||||
<p class="text-text-secondary">Listen to something or like an artist to start getting suggestions.</p>
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<p class="text-text-secondary">
|
||||||
|
{snoozes.length === 0
|
||||||
|
? 'Listen to something or like an artist to start getting suggestions.'
|
||||||
|
: "Nothing new right now — the artists you've parked are below."}
|
||||||
|
</p>
|
||||||
{:else if suggestions.length > 0}
|
{:else if suggestions.length > 0}
|
||||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
||||||
{#each suggestions.filter(visible) as s (s.mbid)}
|
{#each suggestions.filter(visible) as s (s.mbid)}
|
||||||
@@ -66,11 +164,49 @@
|
|||||||
kind="artist"
|
kind="artist"
|
||||||
title={s.name}
|
title={s.name}
|
||||||
imageUrl={s.image_url}
|
imageUrl={s.image_url}
|
||||||
state="requestable"
|
state={cardState(s)}
|
||||||
attribution={attributionText(s.attribution)}
|
attribution={reasonText(s)}
|
||||||
onRequest={() => onRequest(s)}
|
onRequest={() => onRequest(s)}
|
||||||
|
onSnooze={() => onSnooze(s)}
|
||||||
|
onUnsnooze={() => onUnsnooze(s.mbid, s.name)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The snoozed list is not a nicety: a parked suggestion is by definition
|
||||||
|
absent from the deck above, so without this there is no way back. Only
|
||||||
|
rendered when non-empty, so the surface stays quiet for the common case.
|
||||||
|
-->
|
||||||
|
{#if snoozes.length > 0}
|
||||||
|
<section class="mt-8 border-t border-border pt-6" aria-labelledby="snoozed-heading">
|
||||||
|
<h3 id="snoozed-heading" class="font-display text-lg font-medium text-text-primary">
|
||||||
|
Not right now
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-text-secondary">
|
||||||
|
These come back on their own. Nothing here counts against your taste profile.
|
||||||
|
</p>
|
||||||
|
<ul class="mt-3 divide-y divide-border">
|
||||||
|
{#each snoozes as snoozed (snoozed.mbid)}
|
||||||
|
<li class="flex items-center justify-between gap-4 py-2">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="truncate text-sm text-text-primary">{snoozed.name}</div>
|
||||||
|
<div class="text-xs text-text-secondary">
|
||||||
|
Back {returnsIn(snoozed.snoozed_until)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Bring ${snoozed.name} back now`}
|
||||||
|
class="shrink-0 rounded-md border border-border px-3 py-1.5 text-sm text-text-primary hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
onclick={() => onUnsnooze(snoozed.mbid, snoozed.name)}
|
||||||
|
>
|
||||||
|
Bring back
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||||
import { mockQuery } from '../../test-utils/query';
|
import { mockQuery } from '../../test-utils/query';
|
||||||
|
|
||||||
@@ -9,17 +9,30 @@ vi.mock('@tanstack/svelte-query', async (orig) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('$lib/api/suggestions', () => ({
|
vi.mock('$lib/api/suggestions', () => ({
|
||||||
createSuggestionsQuery: vi.fn()
|
createSuggestionsQuery: vi.fn(),
|
||||||
|
createSnoozesQuery: vi.fn(),
|
||||||
|
snoozeSuggestion: vi.fn().mockResolvedValue(undefined),
|
||||||
|
unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/api/requests', () => ({
|
vi.mock('$lib/api/requests', () => ({
|
||||||
createRequest: vi.fn().mockResolvedValue({})
|
createRequest: vi.fn().mockResolvedValue({})
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const pushToastMock = vi.fn();
|
||||||
|
vi.mock('$lib/stores/toast.svelte', () => ({
|
||||||
|
pushToast: (...args: unknown[]) => pushToastMock(...args)
|
||||||
|
}));
|
||||||
|
|
||||||
import SuggestionFeed from './SuggestionFeed.svelte';
|
import SuggestionFeed from './SuggestionFeed.svelte';
|
||||||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
import {
|
||||||
|
createSuggestionsQuery,
|
||||||
|
createSnoozesQuery,
|
||||||
|
snoozeSuggestion,
|
||||||
|
unsnoozeSuggestion
|
||||||
|
} from '$lib/api/suggestions';
|
||||||
import { createRequest } from '$lib/api/requests';
|
import { createRequest } from '$lib/api/requests';
|
||||||
import type { ArtistSuggestion } from '$lib/api/types';
|
import type { ArtistSuggestion, SuggestionSnooze } from '$lib/api/types';
|
||||||
|
|
||||||
const oneSeed: ArtistSuggestion = {
|
const oneSeed: ArtistSuggestion = {
|
||||||
mbid: 'mb1',
|
mbid: 'mb1',
|
||||||
@@ -51,46 +64,50 @@ const threeSeeds: ArtistSuggestion = {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Days from now as an RFC3339 string, for snooze fixtures. */
|
||||||
|
function inDays(n: number): string {
|
||||||
|
return new Date(Date.now() + n * 86_400_000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSuggestions(data: ArtistSuggestion[]) {
|
||||||
|
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSnoozes(data: SuggestionSnooze[]) {
|
||||||
|
(createSnoozesQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => setSnoozes([]));
|
||||||
afterEach(() => vi.clearAllMocks());
|
afterEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
describe('SuggestionFeed', () => {
|
describe('SuggestionFeed', () => {
|
||||||
test('renders one card per suggestion', () => {
|
test('renders one card per suggestion', () => {
|
||||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
setSuggestions([oneSeed, twoSeeds]);
|
||||||
mockQuery({ data: [oneSeed, twoSeeds] })
|
|
||||||
);
|
|
||||||
render(SuggestionFeed);
|
render(SuggestionFeed);
|
||||||
expect(screen.getByText('Outsider')).toBeInTheDocument();
|
expect(screen.getByText('Outsider')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Outsider Two')).toBeInTheDocument();
|
expect(screen.getByText('Outsider Two')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('attribution copy: 1 seed → "Because you liked X."', () => {
|
test('attribution copy: 1 seed → "Because you liked X."', () => {
|
||||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
setSuggestions([oneSeed]);
|
||||||
mockQuery({ data: [oneSeed] })
|
|
||||||
);
|
|
||||||
render(SuggestionFeed);
|
render(SuggestionFeed);
|
||||||
expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument();
|
expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('attribution copy: 2 seeds → "Because you liked A and played B."', () => {
|
test('attribution copy: 2 seeds → "Because you liked A and played B."', () => {
|
||||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
setSuggestions([twoSeeds]);
|
||||||
mockQuery({ data: [twoSeeds] })
|
|
||||||
);
|
|
||||||
render(SuggestionFeed);
|
render(SuggestionFeed);
|
||||||
expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument();
|
expect(screen.getByText(/because you liked a and played b\./i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('attribution copy: 3 seeds → Oxford comma', () => {
|
test('attribution copy: 3 seeds → Oxford comma', () => {
|
||||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
setSuggestions([threeSeeds]);
|
||||||
mockQuery({ data: [threeSeeds] })
|
|
||||||
);
|
|
||||||
render(SuggestionFeed);
|
render(SuggestionFeed);
|
||||||
expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument();
|
expect(screen.getByText(/because you liked x, played y, and played z\./i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Request button calls createRequest with artist-kind body', async () => {
|
test('Request button calls createRequest with artist-kind body', async () => {
|
||||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(
|
setSuggestions([oneSeed]);
|
||||||
mockQuery({ data: [oneSeed] })
|
|
||||||
);
|
|
||||||
render(SuggestionFeed);
|
render(SuggestionFeed);
|
||||||
await fireEvent.click(screen.getByRole('button', { name: /request outsider/i }));
|
await fireEvent.click(screen.getByRole('button', { name: /request outsider/i }));
|
||||||
expect(createRequest).toHaveBeenCalledWith({
|
expect(createRequest).toHaveBeenCalledWith({
|
||||||
@@ -102,8 +119,174 @@ describe('SuggestionFeed', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('empty state when data is []', () => {
|
test('empty state when data is []', () => {
|
||||||
(createSuggestionsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
|
setSuggestions([]);
|
||||||
render(SuggestionFeed);
|
render(SuggestionFeed);
|
||||||
expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument();
|
expect(screen.getByText(/listen to something or like an artist/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SuggestionFeed snooze (#2375)', () => {
|
||||||
|
test('snooze sends BOTH mbid and name — the server 400s without the name', async () => {
|
||||||
|
setSuggestions([oneSeed]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
|
||||||
|
expect(snoozeSuggestion).toHaveBeenCalledWith('mb1', 'Outsider');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the card stays in place showing Undo, rather than vanishing', async () => {
|
||||||
|
setSuggestions([oneSeed]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
|
||||||
|
// Still on screen — the disappearance happens on refetch, not under the
|
||||||
|
// cursor (rule #24).
|
||||||
|
expect(screen.getByText('Outsider')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /bring outsider back/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('status')).toHaveTextContent('Not right now');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed snooze reverts the card and says so', async () => {
|
||||||
|
(snoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('offline'));
|
||||||
|
setSuggestions([oneSeed]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
|
||||||
|
// Back to requestable — a snooze that silently did nothing would leave
|
||||||
|
// the user tapping it again.
|
||||||
|
expect(screen.getByRole('button', { name: /request outsider/i })).toBeInTheDocument();
|
||||||
|
expect(pushToastMock).toHaveBeenCalledWith("Couldn't hide Outsider", 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('undo on the card calls unsnoozeSuggestion', async () => {
|
||||||
|
setSuggestions([oneSeed]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /not right now/i }));
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /bring outsider back/i }));
|
||||||
|
expect(unsnoozeSuggestion).toHaveBeenCalledWith('mb1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the snoozed list is the way back once the card is gone', async () => {
|
||||||
|
// Deck empty, one parked artist: exactly the state after a refetch.
|
||||||
|
setSuggestions([]);
|
||||||
|
setSnoozes([
|
||||||
|
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
|
||||||
|
]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
expect(screen.getByRole('heading', { name: /not right now/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Parked')).toBeInTheDocument();
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
|
||||||
|
expect(unsnoozeSuggestion).toHaveBeenCalledWith('mbX');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 404 from unsnooze is not surfaced as an error', async () => {
|
||||||
|
(unsnoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ status: 404 });
|
||||||
|
setSuggestions([]);
|
||||||
|
setSnoozes([
|
||||||
|
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
|
||||||
|
]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
|
||||||
|
// Already-unsnoozed IS the end state the user asked for.
|
||||||
|
expect(pushToastMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-404 unsnooze failure does surface', async () => {
|
||||||
|
(unsnoozeSuggestion as ReturnType<typeof vi.fn>).mockRejectedValueOnce({ status: 500 });
|
||||||
|
setSuggestions([]);
|
||||||
|
setSnoozes([
|
||||||
|
{ mbid: 'mbX', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
|
||||||
|
]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /bring parked back now/i }));
|
||||||
|
expect(pushToastMock).toHaveBeenCalledWith("Couldn't bring Parked back", 'error');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('return time reads as a relative phrase, not a calendar date', () => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setSnoozes([
|
||||||
|
{ mbid: 'a', name: 'Quarter', snoozed_until: inDays(90), created_at: inDays(0) },
|
||||||
|
{ mbid: 'b', name: 'Fortnight', snoozed_until: inDays(14), created_at: inDays(0) }
|
||||||
|
]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
expect(screen.getByText(/back in about 3 months/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/back in 14 days/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pins the days→months seam. The singular branch was originally dead code
|
||||||
|
// here too: the threshold (45) sat above the divisor (30), so no day count
|
||||||
|
// could round to one month without hitting the days branch first. Kept in
|
||||||
|
// lockstep with SuggestionSnoozeRefTest on Android.
|
||||||
|
test('the days-to-months boundary sits at 30 days, so "a month" is reachable', () => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setSnoozes([
|
||||||
|
{ mbid: 'a', name: 'JustUnder', snoozed_until: inDays(29), created_at: inDays(0) },
|
||||||
|
{ mbid: 'b', name: 'JustOver', snoozed_until: inDays(30), created_at: inDays(0) }
|
||||||
|
]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
expect(screen.getByText(/back in 29 days/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/back in about a month/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no snoozed section when nothing is parked', () => {
|
||||||
|
setSuggestions([oneSeed]);
|
||||||
|
setSnoozes([]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
expect(screen.queryByRole('heading', { name: /not right now/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// An empty deck has two causes now, and the advice differs. Telling someone
|
||||||
|
// who parked everything to go listen to music would be wrong.
|
||||||
|
test('empty-deck copy distinguishes "no signal" from "you parked them all"', () => {
|
||||||
|
setSuggestions([]);
|
||||||
|
setSnoozes([
|
||||||
|
{ mbid: 'a', name: 'Parked', snoozed_until: inDays(90), created_at: inDays(0) }
|
||||||
|
]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
expect(screen.getByText(/nothing new right now/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/listen to something or like an artist/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SuggestionFeed taste-tag reason (#2377)', () => {
|
||||||
|
const tagged = (over: Partial<ArtistSuggestion> = {}): ArtistSuggestion => ({
|
||||||
|
...oneSeed,
|
||||||
|
mbid: 'mbT',
|
||||||
|
name: 'Tag Match',
|
||||||
|
...over
|
||||||
|
});
|
||||||
|
|
||||||
|
test('matched tags replace seed attribution as the reason line', () => {
|
||||||
|
setSuggestions([tagged({ matched_tags: ['shoegaze', 'dream pop'] })]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
expect(
|
||||||
|
screen.getByText(/matches your taste in shoegaze and dream pop\./i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
// The graph-adjacency line is superseded — describing the music beats
|
||||||
|
// describing the graph when we can do both.
|
||||||
|
expect(screen.queryByText(/because you liked/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('one matched tag reads in the singular', () => {
|
||||||
|
setSuggestions([tagged({ matched_tags: ['shoegaze'] })]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
expect(screen.getByText(/matches your taste in shoegaze\./i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('three matched tags use an Oxford comma, matching the seed copy', () => {
|
||||||
|
setSuggestions([tagged({ matched_tags: ['a', 'b', 'c'] })]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
expect(screen.getByText(/matches your taste in a, b, and c\./i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The common case: most candidates have no cached tags, and the card must
|
||||||
|
// still explain itself rather than going blank.
|
||||||
|
test('no matched tags falls back to seed attribution', () => {
|
||||||
|
setSuggestions([oneSeed]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty matched_tags array also falls back, not to an empty line', () => {
|
||||||
|
setSuggestions([tagged({ matched_tags: [] })]);
|
||||||
|
render(SuggestionFeed);
|
||||||
|
expect(screen.getByText(/because you liked seed\./i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -505,6 +505,21 @@ export function removeFromQueue(idx: number): void {
|
|||||||
_error = null;
|
_error = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear the whole queue and stop playback — mirrors removeFromQueue's
|
||||||
|
// empty-queue branch. Also drops the radio/system source + self-heal closure
|
||||||
|
// so the emptied player doesn't try to refill from a now-irrelevant source.
|
||||||
|
export function clearQueue(): void {
|
||||||
|
_queue = [];
|
||||||
|
_index = 0;
|
||||||
|
_state = 'idle';
|
||||||
|
_position = 0;
|
||||||
|
_duration = 0;
|
||||||
|
_error = null;
|
||||||
|
_radioSeedId = null;
|
||||||
|
_queueSource = null;
|
||||||
|
_queueRefetch = null;
|
||||||
|
}
|
||||||
|
|
||||||
export function playFromQueueIndex(idx: number): void {
|
export function playFromQueueIndex(idx: number): void {
|
||||||
if (idx < 0 || idx >= _queue.length) return;
|
if (idx < 0 || idx >= _queue.length) return;
|
||||||
_radioSeedId = null;
|
_radioSeedId = null;
|
||||||
|
|||||||
@@ -168,9 +168,14 @@
|
|||||||
style="display: none"
|
style="display: none"
|
||||||
></audio>
|
></audio>
|
||||||
|
|
||||||
<QueueDrawer />
|
|
||||||
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<!-- QueueDrawer must be inside the provider: its rows render LikeButton,
|
||||||
|
which calls useQueryClient() at init. The drawer's <aside> is always
|
||||||
|
mounted, so the moment the queue is populated (on first play) those
|
||||||
|
LikeButtons instantiate — outside the provider they throw
|
||||||
|
"No QueryClient was found" and abort the play flush. -->
|
||||||
|
<QueueDrawer />
|
||||||
|
|
||||||
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
|
{#if user.value !== null && page.url.pathname !== '/login' && page.url.pathname !== '/now-playing'}
|
||||||
<Shell>{@render children()}</Shell>
|
<Shell>{@render children()}</Shell>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
type TuningSnapshot,
|
type TuningSnapshot,
|
||||||
type WeightProfile,
|
type WeightProfile,
|
||||||
type TasteTuning,
|
type TasteTuning,
|
||||||
|
type DiscoverTuning,
|
||||||
type TrendsResponse,
|
type TrendsResponse,
|
||||||
type TrendSeries,
|
type TrendSeries,
|
||||||
type TrendMarker
|
type TrendMarker
|
||||||
@@ -43,6 +44,11 @@
|
|||||||
{ key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' }
|
{ key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const discoverFields: { key: keyof DiscoverTuning; label: string; hint: string }[] = [
|
||||||
|
{ key: 'tag_overlap_weight', label: 'Taste-tag weight', hint: "How strongly a candidate's tags matching your taste boosts it. score x (1 + w x overlap), so 0 turns the tag term off and ranks on similarity alone. An artist with no cached tags is never penalised." },
|
||||||
|
{ key: 'snooze_days', label: 'Snooze length (days)', hint: 'How long "not right now" parks a suggestion before it returns on its own. Records no opinion about the artist and never feeds the taste profile.' }
|
||||||
|
];
|
||||||
|
|
||||||
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
|
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
|
||||||
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
|
{ scope: 'radio', label: 'Radio', blurb: 'Seed-directed listening — the user picked a direction.' },
|
||||||
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' }
|
{ scope: 'daily_mix', label: 'Daily mixes', blurb: 'For You, Songs like…, and the discovery mixes.' }
|
||||||
@@ -55,11 +61,17 @@
|
|||||||
let saving = $state<TuningScope | null>(null);
|
let saving = $state<TuningScope | null>(null);
|
||||||
|
|
||||||
function fillForm(snap: TuningSnapshot) {
|
function fillForm(snap: TuningSnapshot) {
|
||||||
const f: Record<string, Record<string, string>> = { radio: {}, daily_mix: {}, taste: {} };
|
const f: Record<string, Record<string, string>> = {
|
||||||
|
radio: {},
|
||||||
|
daily_mix: {},
|
||||||
|
taste: {},
|
||||||
|
discover: {}
|
||||||
|
};
|
||||||
for (const p of ['radio', 'daily_mix'] as const) {
|
for (const p of ['radio', 'daily_mix'] as const) {
|
||||||
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]);
|
for (const { key } of weightFields) f[p][key] = String(snap.profiles[p][key]);
|
||||||
}
|
}
|
||||||
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
|
for (const { key } of tasteFields) f.taste[key] = String(snap.taste[key]);
|
||||||
|
for (const { key } of discoverFields) f.discover[key] = String(snap.discover[key]);
|
||||||
form = f;
|
form = f;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,11 +88,17 @@
|
|||||||
|
|
||||||
// A knob deviates when its CURRENT SAVED value differs from shipped;
|
// A knob deviates when its CURRENT SAVED value differs from shipped;
|
||||||
// the dot marks where this install has drifted from defaults.
|
// the dot marks where this install has drifted from defaults.
|
||||||
function deviates(scope: 'radio' | 'daily_mix' | 'taste', key: string): boolean {
|
function deviates(scope: TuningScope, key: string): boolean {
|
||||||
if (!snapshot) return false;
|
if (!snapshot) return false;
|
||||||
if (scope === 'taste') {
|
if (scope === 'taste') {
|
||||||
return snapshot.taste[key as keyof TasteTuning] !== snapshot.shipped.taste[key as keyof TasteTuning];
|
return snapshot.taste[key as keyof TasteTuning] !== snapshot.shipped.taste[key as keyof TasteTuning];
|
||||||
}
|
}
|
||||||
|
if (scope === 'discover') {
|
||||||
|
return (
|
||||||
|
snapshot.discover[key as keyof DiscoverTuning] !==
|
||||||
|
snapshot.shipped.discover[key as keyof DiscoverTuning]
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
snapshot.profiles[scope][key as keyof WeightProfile] !==
|
snapshot.profiles[scope][key as keyof WeightProfile] !==
|
||||||
snapshot.shipped.profiles[scope][key as keyof WeightProfile]
|
snapshot.shipped.profiles[scope][key as keyof WeightProfile]
|
||||||
@@ -90,6 +108,7 @@
|
|||||||
function currentValue(scope: TuningScope, key: string): number {
|
function currentValue(scope: TuningScope, key: string): number {
|
||||||
if (!snapshot) return 0;
|
if (!snapshot) return 0;
|
||||||
if (scope === 'taste') return snapshot.taste[key as keyof TasteTuning];
|
if (scope === 'taste') return snapshot.taste[key as keyof TasteTuning];
|
||||||
|
if (scope === 'discover') return snapshot.discover[key as keyof DiscoverTuning];
|
||||||
return snapshot.profiles[scope][key as keyof WeightProfile];
|
return snapshot.profiles[scope][key as keyof WeightProfile];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +295,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={saving !== null}
|
disabled={saving !== null}
|
||||||
onclick={() => reset(p.scope)}
|
onclick={() => reset(p.scope)}
|
||||||
|
aria-label="Reset {p.label} to defaults"
|
||||||
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Reset to defaults
|
Reset to defaults
|
||||||
@@ -328,6 +348,63 @@
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={saving !== null}
|
disabled={saving !== null}
|
||||||
onclick={() => reset('taste')}
|
onclick={() => reset('taste')}
|
||||||
|
aria-label="Reset taste profile build to defaults"
|
||||||
|
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Reset to defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Discover request surface (#2377). Its own scope, not part of taste:
|
||||||
|
the snooze length lives here, and a snooze deliberately carries no
|
||||||
|
taste signal (#2374). -->
|
||||||
|
<section class="space-y-3 rounded border border-border bg-surface p-4 lg:max-w-xl">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Discover requests</h2>
|
||||||
|
<p class="text-xs text-text-secondary">
|
||||||
|
How the Discover suggestion deck ranks out-of-library artists. Tag coverage for
|
||||||
|
artists you don't own is partial by nature, so an untagged candidate keeps its
|
||||||
|
similarity score rather than being pushed down.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each discoverFields as f (f.key)}
|
||||||
|
<div class="grid grid-cols-[1fr_7rem] items-center gap-2">
|
||||||
|
<label class="text-sm" for="discover-{f.key}" title={f.hint}>
|
||||||
|
{f.label}
|
||||||
|
{#if deviates('discover', f.key)}
|
||||||
|
<span
|
||||||
|
class="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-accent align-middle"
|
||||||
|
title="Deviates from the shipped default ({snapshot.shipped.discover[f.key]})"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="discover-{f.key}"
|
||||||
|
type="number"
|
||||||
|
step={f.key === 'snooze_days' ? '1' : '0.1'}
|
||||||
|
min="0"
|
||||||
|
bind:value={form.discover[f.key]}
|
||||||
|
class="w-full rounded border border-border bg-background px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving !== null}
|
||||||
|
onclick={() => save('discover')}
|
||||||
|
class="rounded bg-accent px-3 py-1.5 text-sm text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save discover
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving !== null}
|
||||||
|
onclick={() => reset('discover')}
|
||||||
|
aria-label="Reset discover requests to defaults"
|
||||||
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
class="rounded border border-border px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Reset to defaults
|
Reset to defaults
|
||||||
|
|||||||
@@ -43,13 +43,25 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
|
|||||||
...over
|
...over
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const discover = (over: Partial<Record<string, number>> = {}) => ({
|
||||||
|
tag_overlap_weight: 1,
|
||||||
|
snooze_days: 90,
|
||||||
|
...over
|
||||||
|
});
|
||||||
|
|
||||||
|
// NOTE the `as TuningSnapshot` cast below: it silences the type error when the
|
||||||
|
// server snapshot grows a field, so a missing key here surfaces as every test
|
||||||
|
// on this page throwing inside fillForm rather than as a compile failure. Add
|
||||||
|
// new scopes to BOTH this fixture and `shipped`.
|
||||||
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
|
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
|
||||||
return {
|
return {
|
||||||
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
||||||
taste: taste(),
|
taste: taste(),
|
||||||
|
discover: discover(),
|
||||||
shipped: {
|
shipped: {
|
||||||
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
|
||||||
taste: taste()
|
taste: taste(),
|
||||||
|
discover: discover()
|
||||||
},
|
},
|
||||||
...over
|
...over
|
||||||
} as TuningSnapshot;
|
} as TuningSnapshot;
|
||||||
@@ -99,13 +111,17 @@ describe('Admin tuning page', () => {
|
|||||||
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.'));
|
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Targets the button by its accessible name, not its position. This used to
|
||||||
|
// click the LAST reset button and assume that meant taste — which silently
|
||||||
|
// became the wrong scope the moment a card was added below it.
|
||||||
test('reset calls resetTuning for the scope', async () => {
|
test('reset calls resetTuning for the scope', async () => {
|
||||||
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||||
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||||
render(TuningPage);
|
render(TuningPage);
|
||||||
await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument());
|
||||||
const resetButtons = screen.getAllByRole('button', { name: /reset to defaults/i });
|
await fireEvent.click(
|
||||||
await fireEvent.click(resetButtons[resetButtons.length - 1]);
|
screen.getByRole('button', { name: /reset taste profile build to defaults/i })
|
||||||
|
);
|
||||||
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste'));
|
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -198,4 +214,62 @@ describe('Admin tuning page', () => {
|
|||||||
expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument()
|
expect(screen.getByText(/trends appear once listening accumulates/i)).toBeInTheDocument()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
// --- Discover scope (#2377) ---
|
||||||
|
|
||||||
|
test('renders the Discover card with its two knobs', async () => {
|
||||||
|
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||||
|
render(TuningPage);
|
||||||
|
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
|
||||||
|
expect(screen.getByLabelText(/taste-tag weight/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/snooze length/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saving Discover patches only the changed field under the discover scope', async () => {
|
||||||
|
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||||
|
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||||
|
render(TuningPage);
|
||||||
|
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
|
||||||
|
await fireEvent.input(screen.getByLabelText(/taste-tag weight/i), {
|
||||||
|
target: { value: '2.5' }
|
||||||
|
});
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /save discover/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(patchTuning).toHaveBeenCalledWith('discover', { tag_overlap_weight: 2.5 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 0 is the operator's off switch for the whole tag term, so the form must be
|
||||||
|
// able to send it — a falsy-value bug here would silently make the feature
|
||||||
|
// impossible to disable.
|
||||||
|
test('a Discover weight of 0 is sent, not dropped as falsy', async () => {
|
||||||
|
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||||
|
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||||
|
render(TuningPage);
|
||||||
|
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
|
||||||
|
await fireEvent.input(screen.getByLabelText(/taste-tag weight/i), { target: { value: '0' } });
|
||||||
|
await fireEvent.click(screen.getByRole('button', { name: /save discover/i }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(patchTuning).toHaveBeenCalledWith('discover', { tag_overlap_weight: 0 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reset targets the discover scope', async () => {
|
||||||
|
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||||
|
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
|
||||||
|
render(TuningPage);
|
||||||
|
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
|
||||||
|
await fireEvent.click(
|
||||||
|
screen.getByRole('button', { name: /reset discover requests to defaults/i })
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('discover'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a deviating Discover knob is dotted', async () => {
|
||||||
|
const snap = snapshot();
|
||||||
|
snap.discover = discover({ tag_overlap_weight: 3 });
|
||||||
|
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snap);
|
||||||
|
render(TuningPage);
|
||||||
|
await waitFor(() => expect(screen.getByText('Discover requests')).toBeInTheDocument());
|
||||||
|
expect(screen.getByTitle(/deviates from the shipped default \(1\)/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,8 +21,16 @@ vi.mock('$lib/api/lidarr', () => ({
|
|||||||
createLidarrSearchQuery: vi.fn()
|
createLidarrSearchQuery: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// SuggestionFeed reaches for the snooze surface too (#2375). These are
|
||||||
|
// stubbed here even though this page-level suite asserts nothing about
|
||||||
|
// snoozing: a factory-shaped module mock must export everything the
|
||||||
|
// component tree imports, or rendering the feed throws before any
|
||||||
|
// assertion runs.
|
||||||
vi.mock('$lib/api/suggestions', () => ({
|
vi.mock('$lib/api/suggestions', () => ({
|
||||||
createSuggestionsQuery: vi.fn()
|
createSuggestionsQuery: vi.fn(),
|
||||||
|
createSnoozesQuery: vi.fn(),
|
||||||
|
snoozeSuggestion: vi.fn().mockResolvedValue(undefined),
|
||||||
|
unsnoozeSuggestion: vi.fn().mockResolvedValue(undefined)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/api/requests', () => ({
|
vi.mock('$lib/api/requests', () => ({
|
||||||
@@ -42,11 +50,12 @@ vi.mock('@tanstack/svelte-query', async (importOriginal) => {
|
|||||||
|
|
||||||
import DiscoverPage from './+page.svelte';
|
import DiscoverPage from './+page.svelte';
|
||||||
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
||||||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
import { createSuggestionsQuery, createSnoozesQuery } from '$lib/api/suggestions';
|
||||||
import { createRequest } from '$lib/api/requests';
|
import { createRequest } from '$lib/api/requests';
|
||||||
|
|
||||||
const mockedCreateQuery = createLidarrSearchQuery as ReturnType<typeof vi.fn>;
|
const mockedCreateQuery = createLidarrSearchQuery as ReturnType<typeof vi.fn>;
|
||||||
const mockedCreateSuggestionsQuery = createSuggestionsQuery as ReturnType<typeof vi.fn>;
|
const mockedCreateSuggestionsQuery = createSuggestionsQuery as ReturnType<typeof vi.fn>;
|
||||||
|
const mockedCreateSnoozesQuery = createSnoozesQuery as ReturnType<typeof vi.fn>;
|
||||||
const mockedCreateRequest = createRequest as ReturnType<typeof vi.fn>;
|
const mockedCreateRequest = createRequest as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
function result(over: Partial<LidarrSearchResult> = {}): LidarrSearchResult {
|
function result(over: Partial<LidarrSearchResult> = {}): LidarrSearchResult {
|
||||||
@@ -69,6 +78,9 @@ beforeEach(() => {
|
|||||||
// Default: empty suggestion feed so its empty-state copy renders without
|
// Default: empty suggestion feed so its empty-state copy renders without
|
||||||
// interfering with search-mode tests.
|
// interfering with search-mode tests.
|
||||||
mockedCreateSuggestionsQuery.mockReturnValue(mockQuery({ data: [] }));
|
mockedCreateSuggestionsQuery.mockReturnValue(mockQuery({ data: [] }));
|
||||||
|
// Nothing parked, which keeps the feed's empty-state copy on the
|
||||||
|
// "no signal yet" branch that this suite's assertions expect.
|
||||||
|
mockedCreateSnoozesQuery.mockReturnValue(mockQuery({ data: [] }));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ if (typeof window !== 'undefined') {
|
|||||||
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession });
|
Object.defineProperty(window, 'sessionStorage', { configurable: true, value: memSession });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// jsdom doesn't implement Element.prototype.scrollIntoView. Components that
|
||||||
|
// call it (queue auto-scroll to the now-playing row, the alphabetical rail)
|
||||||
|
// would throw an unhandled TypeError in tests — which fails the run even when
|
||||||
|
// every assertion passes. No-op it; tests never assert on scroll position.
|
||||||
|
if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) {
|
||||||
|
Element.prototype.scrollIntoView = () => {};
|
||||||
|
}
|
||||||
|
|
||||||
// W-T3 moved toast rendering out of per-page markup into a single
|
// W-T3 moved toast rendering out of per-page markup into a single
|
||||||
// <ToastHost /> mounted in +layout.svelte. Tests render individual pages
|
// <ToastHost /> mounted in +layout.svelte. Tests render individual pages
|
||||||
// without the layout, so we mount ToastHost here so `pushToast()` calls
|
// without the layout, so we mount ToastHost here so `pushToast()` calls
|
||||||
|
|||||||
Reference in New Issue
Block a user