Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e258507bb | |||
| fd7d6dac4c | |||
| d6290a3ef0 | |||
| 9550d8daaf | |||
| 4d5af3599e | |||
| db393bbe65 | |||
| 1a7515e6ea | |||
| 6c26ba807e | |||
| c7adf2c87a | |||
| 7b7bd0c3e8 | |||
| 26c368c35e | |||
| 36786defd1 | |||
| 8019537b02 | |||
| 3f240bc777 | |||
| e41d603c12 | |||
| 1cc9553d1f | |||
| a62f07bd3a | |||
| c2f9bbaff4 | |||
| abc225e12a | |||
| 3c646c6974 | |||
| aff346c731 | |||
| 13b3fca949 | |||
| 6e0d0e5723 | |||
| 752906b054 | |||
| fdd14ef04c | |||
| 9a57dc4bec | |||
| 4ecb1680bf | |||
| d2a22e49e3 | |||
| f167ddfbfb | |||
| 653ed95f14 | |||
| b2c6f6f0e9 | |||
| 962b4dbc8c | |||
| aa4089118e | |||
| dabca3ad24 | |||
| 7c791dc8e4 | |||
| 8f29cc7414 | |||
| c8b21aa76e | |||
| 483804fc9e | |||
| 12a8cfccb5 | |||
| e358a92cf4 | |||
| 63b25e65ad | |||
| a766b7193f | |||
| e95138d412 | |||
| 7426f6c718 | |||
| e994aae613 | |||
| c39a9ca18f | |||
| 06e155abb6 | |||
| 6b11208e5a | |||
| 41da012494 | |||
| 8a3104443c | |||
| 5f5ab69da6 | |||
| 5386ae870f | |||
| d7b011e52f |
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.api.endpoints
|
||||
|
||||
import com.fabledsword.minstrel.models.wire.AlbumDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistDetailWire
|
||||
import com.fabledsword.minstrel.models.wire.ArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.TrackWire
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
@@ -35,9 +36,26 @@ interface LibraryApi {
|
||||
@GET("api/artists/{id}/tracks")
|
||||
suspend fun getArtistTracks(@Path("id") id: String): List<TrackWire>
|
||||
|
||||
@GET("api/artists/{id}/similar")
|
||||
suspend fun getSimilarArtists(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = SIMILAR_ARTISTS_LIMIT,
|
||||
): List<ArtistWire>
|
||||
|
||||
@GET("api/artists/{id}/top-tracks")
|
||||
suspend fun getArtistTopTracks(
|
||||
@Path("id") id: String,
|
||||
@Query("limit") limit: Int = TOP_TRACKS_LIMIT,
|
||||
): List<TrackWire>
|
||||
|
||||
@GET("api/albums/{id}")
|
||||
suspend fun getAlbumDetail(@Path("id") id: String): AlbumDetailWire
|
||||
|
||||
@GET("api/library/shuffle")
|
||||
suspend fun shuffleLibrary(@Query("limit") limit: Int = 100): List<TrackWire>
|
||||
|
||||
private companion object {
|
||||
const val SIMILAR_ARTISTS_LIMIT = 12
|
||||
const val TOP_TRACKS_LIMIT = 5
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+5
@@ -14,6 +14,11 @@ import kotlinx.datetime.Instant
|
||||
* - "rediscover_artists"
|
||||
* - "most_played_tracks"
|
||||
* - "last_played_artists"
|
||||
* - "you_might_like_albums"
|
||||
* - "you_might_like_artists"
|
||||
*
|
||||
* New `section` values need no schema change — the column stores the
|
||||
* string verbatim and the DAO keys on it.
|
||||
*
|
||||
* `entityType` is "album" | "artist" | "track" — dispatches hydration
|
||||
* to the right per-entity endpoint when a tile is rendered.
|
||||
|
||||
+31
@@ -28,6 +28,13 @@ object MutationKind {
|
||||
const val PLAY_OFFLINE: String = "play_offline"
|
||||
const val REQUEST_CANCEL: String = "request_cancel"
|
||||
const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"
|
||||
|
||||
// Durable close-by-id for a play whose server row is already open
|
||||
// (play_started succeeded). Distinct from PLAY_OFFLINE, which records
|
||||
// a *whole* play when no server row exists. Using close-by-id on
|
||||
// background avoids the duplicate + orphan row the old offline-on-stop
|
||||
// path produced (see 2026-06-11 contract audit).
|
||||
const val PLAY_ENDED: String = "play_ended"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +174,17 @@ class MutationQueue @Inject constructor(
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Durable close-by-id for an already-open server play row. Background
|
||||
* path only (no user hint) — same as [enqueuePlayOffline].
|
||||
*/
|
||||
suspend fun enqueuePlayEnded(payload: PlayEndedPayload): Long = dao.insert(
|
||||
CachedMutationEntity(
|
||||
kind = MutationKind.PLAY_ENDED,
|
||||
payload = json.encodeToString(PlayEndedPayload.serializer(), payload),
|
||||
),
|
||||
)
|
||||
|
||||
private suspend fun insertUserDriven(kind: String, payload: String): Long {
|
||||
val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
|
||||
_userEnqueueHints.tryEmit(QUEUED_MESSAGE)
|
||||
@@ -223,6 +241,19 @@ data class PlayOfflinePayload(
|
||||
val source: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.PLAY_ENDED`. The replayer re-fires
|
||||
* `POST /api/events` with type=play_ended, closing the already-open server
|
||||
* row [playEventId] at [durationPlayedMs]. Server-side close-by-id is
|
||||
* idempotent (re-ending updates the same row and skips a duplicate
|
||||
* skip_events insert), so a lost-response retry can't duplicate history.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlayEndedPayload(
|
||||
val playEventId: String,
|
||||
val durationPlayedMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Persisted payload for `MutationKind.REQUEST_CANCEL` — the
|
||||
* `POST /api/requests/{id}/cancel` call lost during a connectivity
|
||||
|
||||
+170
-157
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("TooManyFunctions") // one dispatcher per mutation kind + replay helpers
|
||||
|
||||
package com.fabledsword.minstrel.cache.mutations
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
|
||||
@@ -10,6 +12,9 @@ import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
||||
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
|
||||
import com.fabledsword.minstrel.api.endpoints.RequestsApi
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||
@@ -19,12 +24,16 @@ import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Inject
|
||||
@@ -34,25 +43,24 @@ import javax.inject.Singleton
|
||||
* Drains the offline MutationQueue. Reads pending rows in FIFO order,
|
||||
* re-fires each call against the raw Retrofit API (NOT the high-level
|
||||
* Repository, which would re-enqueue on failure → infinite loop), and
|
||||
* deletes the row on 2xx success.
|
||||
* resolves each to an [Outcome]:
|
||||
* - SENT — server accepted (2xx) → delete the row.
|
||||
* - DROP — permanently un-sendable (4xx, unknown kind/variant, corrupt
|
||||
* payload) → delete the row so it can't wedge the queue forever.
|
||||
* - RETRY — transient (transport, 5xx, 408, 429) → leave queued, bump
|
||||
* attempts, retry on the next trigger.
|
||||
*
|
||||
* Triggered by:
|
||||
* - app start (MinstrelApplication @Inject forces the singleton's
|
||||
* init block to subscribe)
|
||||
* - every time AuthStore.sessionCookie transitions to a non-null
|
||||
* value (sign-in, app launch with persisted cookie)
|
||||
* - app start (MinstrelApplication @Inject forces the singleton's init)
|
||||
* - AuthStore.sessionCookie transitioning to non-null (sign-in / cold
|
||||
* start with a persisted cookie)
|
||||
* - NetworkStatusController recovering to Healthy (server reachable again
|
||||
* mid-session — covers offline→online without needing a cold start)
|
||||
*
|
||||
* Single in-flight via the `mutex` — back-to-back triggers collapse
|
||||
* into one drain pass. A proper connectivity-listener / WorkManager
|
||||
* job that re-tries on Wi-Fi-came-back is a follow-up; for MVP the
|
||||
* sign-in + app-open triggers cover the common offline → online
|
||||
* transition (operator unlocks phone, opens app).
|
||||
*
|
||||
* Failed rows stay in the queue with their `attempts` and
|
||||
* `lastAttemptAt` updated. They get another chance on the next
|
||||
* trigger; no exponential backoff or max-attempts yet — the rows are
|
||||
* small (low single-digit count for typical use) so retry-forever is
|
||||
* cheap.
|
||||
* Single in-flight via the [mutex]; back-to-back triggers collapse into one
|
||||
* drain pass. No exponential backoff / max-attempts: the rows are few for
|
||||
* typical use, and permanent failures are now dropped rather than retried
|
||||
* forever.
|
||||
*/
|
||||
@Singleton
|
||||
class MutationReplayer @Inject constructor(
|
||||
@@ -60,6 +68,7 @@ class MutationReplayer @Inject constructor(
|
||||
private val authStore: AuthStore,
|
||||
private val json: Json,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
networkStatus: NetworkStatusController,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val likesApi: LikesApi = retrofit.create()
|
||||
@@ -72,6 +81,8 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private val mutex = Mutex()
|
||||
|
||||
private enum class Outcome { SENT, DROP, RETRY }
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
authStore.sessionCookie
|
||||
@@ -79,6 +90,16 @@ class MutationReplayer @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
.collect { drainSafe() }
|
||||
}
|
||||
// Server-reachable-again: replay queued writes the moment the
|
||||
// health signal recovers, so an offline→online transition mid-
|
||||
// session doesn't wait for the next cold start / re-auth.
|
||||
scope.launch {
|
||||
networkStatus.state
|
||||
.map { it == ServerHealth.Healthy }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { drainSafe() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Public entry-point; coalesces concurrent triggers via a Mutex. */
|
||||
@@ -93,193 +114,185 @@ class MutationReplayer @Inject constructor(
|
||||
|
||||
private suspend fun drain() {
|
||||
val rows = dao.getAll()
|
||||
// Collapse superseded like-toggles: only the latest desired state per
|
||||
// (entity) is replayed; older toggles for the same entity are dropped
|
||||
// unsent. Without this, partial-failure + differential retry could
|
||||
// replay an older toggle last and invert the final like state.
|
||||
val superseded = supersededLikeToggleIds(rows)
|
||||
for (row in rows) {
|
||||
val sent = runCatching { dispatch(row) }.getOrDefault(false)
|
||||
if (sent) {
|
||||
if (row.id in superseded) {
|
||||
dao.delete(row.id)
|
||||
} else {
|
||||
dao.recordAttempt(row.id, Clock.System.now())
|
||||
continue
|
||||
}
|
||||
when (outcomeFor(row)) {
|
||||
Outcome.SENT, Outcome.DROP -> dao.delete(row.id)
|
||||
Outcome.RETRY -> dao.recordAttempt(row.id, Clock.System.now())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Row ids of like-toggles superseded by a later toggle for the same entity. */
|
||||
private fun supersededLikeToggleIds(rows: List<CachedMutationEntity>): Set<Long> {
|
||||
val latestByEntity = HashMap<String, Long>()
|
||||
val superseded = HashSet<Long>()
|
||||
rows.asSequence()
|
||||
.filter { it.kind == MutationKind.LIKE_TOGGLE }
|
||||
.forEach { row ->
|
||||
val decoded = runCatching {
|
||||
json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
|
||||
}.getOrNull()
|
||||
if (decoded != null) {
|
||||
val key = "${decoded.entityType}:${decoded.entityId}"
|
||||
// `rows` is ascending by id, so a prior entry is always older.
|
||||
latestByEntity.put(key, row.id)?.let(superseded::add)
|
||||
}
|
||||
}
|
||||
return superseded
|
||||
}
|
||||
|
||||
private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
|
||||
dispatch(row)
|
||||
} catch (e: HttpException) {
|
||||
if (isPermanent(e.code())) Outcome.DROP else Outcome.RETRY
|
||||
} catch (@Suppress("SwallowedException") e: SerializationException) {
|
||||
// Corrupt / schema-incompatible payload — permanent, drop it.
|
||||
Outcome.DROP
|
||||
} catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable) {
|
||||
// Transport / unknown — leave queued for the next pass.
|
||||
Outcome.RETRY
|
||||
}
|
||||
|
||||
/** 4xx are 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
|
||||
|
||||
/**
|
||||
* Returns true if the server accepted the call, false on any
|
||||
* failure (transport, server error, decode). The replayer treats
|
||||
* false as "leave the row for next pass" — it doesn't try to
|
||||
* distinguish permanent rejections from transient ones; a row
|
||||
* that consistently fails will accumulate attempt count and
|
||||
* lastAttemptAt for diagnostics.
|
||||
* Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
|
||||
* unknown kind/variant. Network / HTTP failures THROW and are classified
|
||||
* by [outcomeFor] — dispatchers no longer swallow.
|
||||
*/
|
||||
private suspend fun dispatch(row: CachedMutationEntity): Boolean = when (row.kind) {
|
||||
private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
|
||||
MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
|
||||
MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
|
||||
MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
|
||||
MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
|
||||
MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
|
||||
MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
|
||||
MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
|
||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||
else -> {
|
||||
// Unknown kind — drop the row by claiming success so a
|
||||
// stale schema entry can't wedge the queue forever.
|
||||
true
|
||||
}
|
||||
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||
else -> Outcome.DROP
|
||||
}
|
||||
|
||||
private suspend fun dispatchLikeToggle(payload: String): Boolean {
|
||||
private suspend fun dispatchLikeToggle(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
|
||||
val kindPath = when (decoded.entityType) {
|
||||
LikesRepository.ENTITY_ARTIST -> "artists"
|
||||
LikesRepository.ENTITY_ALBUM -> "albums"
|
||||
LikesRepository.ENTITY_TRACK -> "tracks"
|
||||
else -> return true // unknown variant — drop.
|
||||
else -> return Outcome.DROP
|
||||
}
|
||||
return try {
|
||||
if (decoded.desiredState) {
|
||||
likesApi.like(kindPath, decoded.entityId)
|
||||
} else {
|
||||
likesApi.unlike(kindPath, decoded.entityId)
|
||||
}
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
if (decoded.desiredState) {
|
||||
likesApi.like(kindPath, decoded.entityId)
|
||||
} else {
|
||||
likesApi.unlike(kindPath, decoded.entityId)
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCreate(payload: String): Boolean {
|
||||
private suspend fun dispatchRequestCreate(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
|
||||
val body = CreateRequestBody(
|
||||
kind = decoded.kind,
|
||||
artistMbid = decoded.artistMbid,
|
||||
artistName = decoded.artistName,
|
||||
albumMbid = decoded.albumMbid,
|
||||
albumTitle = decoded.albumTitle,
|
||||
trackMbid = decoded.trackMbid,
|
||||
trackTitle = decoded.trackTitle,
|
||||
discoverApi.createRequest(
|
||||
CreateRequestBody(
|
||||
kind = decoded.kind,
|
||||
artistMbid = decoded.artistMbid,
|
||||
artistName = decoded.artistName,
|
||||
albumMbid = decoded.albumMbid,
|
||||
albumTitle = decoded.albumTitle,
|
||||
trackMbid = decoded.trackMbid,
|
||||
trackTitle = decoded.trackTitle,
|
||||
),
|
||||
)
|
||||
return try {
|
||||
discoverApi.createRequest(body)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
}
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchQuarantineUnflag(payload: String): Boolean {
|
||||
private suspend fun dispatchQuarantineUnflag(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
|
||||
return try {
|
||||
quarantineApi.unflag(decoded.trackId)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: the row stays in the queue with
|
||||
// attempts + lastAttemptAt incremented; next drain pass
|
||||
// retries. Per-attempt diagnostic logging lands when we
|
||||
// wire up Timber at the replayer level.
|
||||
false
|
||||
}
|
||||
quarantineApi.unflag(decoded.trackId)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaylistAppend(payload: String): Boolean {
|
||||
private suspend fun dispatchPlaylistAppend(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
|
||||
return try {
|
||||
playlistsApi.appendTracks(
|
||||
playlistId = decoded.playlistId,
|
||||
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
playlistsApi.appendTracks(
|
||||
playlistId = decoded.playlistId,
|
||||
body = AppendTracksRequest(trackIds = decoded.trackIds),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchQuarantineFlag(payload: String): Boolean {
|
||||
private suspend fun dispatchQuarantineFlag(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
|
||||
return try {
|
||||
quarantineApi.flag(
|
||||
FlagRequest(
|
||||
trackId = decoded.trackId,
|
||||
reason = decoded.reason,
|
||||
notes = decoded.notes,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
quarantineApi.flag(
|
||||
FlagRequest(
|
||||
trackId = decoded.trackId,
|
||||
reason = decoded.reason,
|
||||
notes = decoded.notes,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlayOffline(payload: String): Boolean {
|
||||
private suspend fun dispatchPlayOffline(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
|
||||
return try {
|
||||
eventsApi.playOffline(
|
||||
PlayOfflineRequest(
|
||||
trackId = decoded.trackId,
|
||||
clientId = decoded.clientId,
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
eventsApi.playOffline(
|
||||
PlayOfflineRequest(
|
||||
trackId = decoded.trackId,
|
||||
clientId = decoded.clientId,
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCancel(payload: String): Boolean {
|
||||
private suspend fun dispatchPlayEnded(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlayEndedPayload.serializer(), payload)
|
||||
eventsApi.playEnded(
|
||||
PlayEndedRequest(
|
||||
playEventId = decoded.playEventId,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchRequestCancel(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
|
||||
return try {
|
||||
requestsApi.cancel(decoded.requestId)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
requestsApi.cancel(decoded.requestId)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Boolean {
|
||||
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||
return try {
|
||||
playbackErrorsApi.report(
|
||||
PlaybackErrorReportRequest(
|
||||
trackId = decoded.trackId,
|
||||
kind = decoded.kind,
|
||||
detail = decoded.detail,
|
||||
clientId = decoded.clientId,
|
||||
),
|
||||
)
|
||||
true
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Intentional swallow: row stays queued; next drain pass retries.
|
||||
false
|
||||
}
|
||||
playbackErrorsApi.report(
|
||||
PlaybackErrorReportRequest(
|
||||
trackId = decoded.trackId,
|
||||
kind = decoded.kind,
|
||||
detail = decoded.detail,
|
||||
clientId = decoded.clientId,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
+25
-1
@@ -2,6 +2,8 @@ package com.fabledsword.minstrel.cache.sync
|
||||
|
||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
|
||||
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
|
||||
@@ -16,8 +18,11 @@ import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.datetime.Clock
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
@@ -50,10 +55,15 @@ class SyncController @Inject constructor(
|
||||
private val trackDao: CachedTrackDao,
|
||||
private val authStore: AuthStore,
|
||||
@ApplicationScope private val scope: CoroutineScope,
|
||||
networkStatus: NetworkStatusController,
|
||||
retrofit: Retrofit,
|
||||
) {
|
||||
private val api: SyncApi = retrofit.create()
|
||||
|
||||
// Single in-flight guard: the cookie + health-recovery triggers (and a
|
||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||
private val mutex = Mutex()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
authStore.sessionCookie
|
||||
@@ -61,11 +71,25 @@ class SyncController @Inject constructor(
|
||||
.distinctUntilChanged()
|
||||
.collect { syncSafe() }
|
||||
}
|
||||
// Re-sync when the server becomes reachable again mid-session, so a
|
||||
// delta missed while offline lands without waiting for a cold start.
|
||||
scope.launch {
|
||||
networkStatus.state
|
||||
.map { it == ServerHealth.Healthy }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.collect { syncSafe() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
||||
suspend fun syncSafe() {
|
||||
runCatching { sync() }
|
||||
if (!mutex.tryLock()) return
|
||||
try {
|
||||
runCatching { sync() }
|
||||
} finally {
|
||||
mutex.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sync() {
|
||||
|
||||
@@ -39,6 +39,9 @@ import javax.inject.Singleton
|
||||
* pre-warms the top artists via [HomeArtistPrewarmer].
|
||||
*/
|
||||
@Singleton
|
||||
// Per-section observe accessors (one per Home row) inflate the function
|
||||
// count past detekt's 11 default — accessor density, not complexity.
|
||||
@Suppress("TooManyFunctions")
|
||||
class HomeRepository @Inject constructor(
|
||||
private val homeIndexDao: CachedHomeIndexDao,
|
||||
private val metadataProvider: MetadataProvider,
|
||||
@@ -76,6 +79,12 @@ class HomeRepository @Inject constructor(
|
||||
fun observeLastPlayedArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||
observeArtistSection(SECTION_LAST_PLAYED_ARTISTS)
|
||||
|
||||
fun observeYouMightLikeAlbums(): Flow<List<HomeTile<AlbumRef>>> =
|
||||
observeAlbumSection(SECTION_YOU_MIGHT_LIKE_ALBUMS)
|
||||
|
||||
fun observeYouMightLikeArtists(): Flow<List<HomeTile<ArtistRef>>> =
|
||||
observeArtistSection(SECTION_YOU_MIGHT_LIKE_ARTISTS)
|
||||
|
||||
/**
|
||||
* Pulls /api/home/index, replaces each cached_home_index section,
|
||||
* and pre-warms the top artists. The section Flows re-fire on the
|
||||
@@ -88,7 +97,11 @@ class HomeRepository @Inject constructor(
|
||||
replaceSection(SECTION_REDISCOVER_ARTISTS, "artist", wire.rediscoverArtists)
|
||||
replaceSection(SECTION_MOST_PLAYED_TRACKS, "track", wire.mostPlayedTracks)
|
||||
replaceSection(SECTION_LAST_PLAYED_ARTISTS, "artist", wire.lastPlayedArtists)
|
||||
prewarmer.warm(wire.rediscoverArtists + wire.lastPlayedArtists)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ALBUMS, "album", wire.youMightLikeAlbums)
|
||||
replaceSection(SECTION_YOU_MIGHT_LIKE_ARTISTS, "artist", wire.youMightLikeArtists)
|
||||
prewarmer.warm(
|
||||
wire.rediscoverArtists + wire.lastPlayedArtists + wire.youMightLikeArtists,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun replaceSection(section: String, entityType: String, ids: List<String>) {
|
||||
@@ -148,5 +161,7 @@ class HomeRepository @Inject constructor(
|
||||
const val SECTION_REDISCOVER_ARTISTS = "rediscover_artists"
|
||||
const val SECTION_MOST_PLAYED_TRACKS = "most_played_tracks"
|
||||
const val SECTION_LAST_PLAYED_ARTISTS = "last_played_artists"
|
||||
const val SECTION_YOU_MIGHT_LIKE_ALBUMS = "you_might_like_albums"
|
||||
const val SECTION_YOU_MIGHT_LIKE_ARTISTS = "you_might_like_artists"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,8 @@ data class HomeSections(
|
||||
val rediscoverArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
val mostPlayedTracks: List<HomeTile<TrackRef>> = emptyList(),
|
||||
val lastPlayedArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
val youMightLikeAlbums: List<HomeTile<AlbumRef>> = emptyList(),
|
||||
val youMightLikeArtists: List<HomeTile<ArtistRef>> = emptyList(),
|
||||
) {
|
||||
val isAllEmpty: Boolean
|
||||
get() = playlists.isEmpty() &&
|
||||
@@ -123,7 +125,9 @@ data class HomeSections(
|
||||
rediscoverAlbums.isEmpty() &&
|
||||
rediscoverArtists.isEmpty() &&
|
||||
mostPlayedTracks.isEmpty() &&
|
||||
lastPlayedArtists.isEmpty()
|
||||
lastPlayedArtists.isEmpty() &&
|
||||
youMightLikeAlbums.isEmpty() &&
|
||||
youMightLikeArtists.isEmpty()
|
||||
}
|
||||
|
||||
// ─── ViewModel ───────────────────────────────────────────────────────
|
||||
@@ -289,13 +293,14 @@ class HomeViewModel @Inject constructor(
|
||||
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
||||
|
||||
/**
|
||||
* Five home-section flows merged into the sections struct without
|
||||
* playlists; then merged with the playlists flow in [combineHomeFlows].
|
||||
* Splitting it like this keeps each combine call inside the
|
||||
* coroutines built-in arity limit (5) and avoids the typed-vararg
|
||||
* acrobatics needed for a 6-flow combine across heterogeneous types.
|
||||
* The core five section flows merged into the sections struct. Split
|
||||
* out so each combine call stays inside the coroutines built-in arity
|
||||
* limit (5) and avoids the typed-vararg acrobatics a wider combine
|
||||
* across heterogeneous types would need. [observeHomeSections] then
|
||||
* folds in the two you-might-like flows, and [combineHomeFlows] the
|
||||
* playlists flow.
|
||||
*/
|
||||
private fun observeHomeSections() = combine(
|
||||
private fun observeCoreSections() = combine(
|
||||
homeRepository.observeRecentlyAddedAlbums(),
|
||||
homeRepository.observeRediscoverAlbums(),
|
||||
homeRepository.observeRediscoverArtists(),
|
||||
@@ -311,6 +316,14 @@ class HomeViewModel @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
private fun observeHomeSections() = combine(
|
||||
observeCoreSections(),
|
||||
homeRepository.observeYouMightLikeAlbums(),
|
||||
homeRepository.observeYouMightLikeArtists(),
|
||||
) { core, ymlAlbums, ymlArtists ->
|
||||
core.copy(youMightLikeAlbums = ymlAlbums, youMightLikeArtists = ymlArtists)
|
||||
}
|
||||
|
||||
private fun combineHomeFlows() =
|
||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
||||
val merged = sections.copy(playlists = playlists)
|
||||
@@ -463,6 +476,14 @@ private fun HomeSuccessContent(
|
||||
onPlayPlaylist = onPlayPlaylist,
|
||||
)
|
||||
}
|
||||
youMightLikeSection(
|
||||
albums = sections.youMightLikeAlbums,
|
||||
artists = sections.youMightLikeArtists,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlayAlbum = onPlayAlbum,
|
||||
onPlayArtist = onPlayArtist,
|
||||
)
|
||||
recentlyAddedSection(sections.recentlyAddedAlbums, onAlbumClick, onPlayAlbum)
|
||||
rediscoverSection(
|
||||
albums = sections.rediscoverAlbums,
|
||||
@@ -667,6 +688,61 @@ private fun RediscoverBlock(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "You might like" — in-library albums/artists predicted from the user's
|
||||
* taste that they don't actively spin (server section, daily-built, cold-
|
||||
* start gated). Sits directly under the system-playlists row. Same
|
||||
* albums-then-artists block shape as Rediscover; the artist sub-row drops
|
||||
* its title when an album sub-row precedes it so the two read as one section.
|
||||
*/
|
||||
private fun LazyListScope.youMightLikeSection(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
artists: List<HomeTile<ArtistRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onPlayAlbum: suspend (String) -> Unit,
|
||||
onPlayArtist: suspend (String) -> Unit,
|
||||
) {
|
||||
item {
|
||||
if (albums.isNotEmpty() || artists.isNotEmpty()) {
|
||||
YouMightLikeBlock(
|
||||
albums = albums,
|
||||
artists = artists,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistClick = onArtistClick,
|
||||
onPlayAlbum = onPlayAlbum,
|
||||
onPlayArtist = onPlayArtist,
|
||||
)
|
||||
} else {
|
||||
EmptySection(
|
||||
title = "You might like",
|
||||
body = "We're still learning your taste — keep listening and " +
|
||||
"picks you might like will show up here.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun YouMightLikeBlock(
|
||||
albums: List<HomeTile<AlbumRef>>,
|
||||
artists: List<HomeTile<ArtistRef>>,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
onPlayAlbum: suspend (String) -> Unit,
|
||||
onPlayArtist: suspend (String) -> Unit,
|
||||
) {
|
||||
Column {
|
||||
if (albums.isNotEmpty()) {
|
||||
AlbumsRow("You might like", albums, onAlbumClick, onPlayAlbum)
|
||||
}
|
||||
if (artists.isNotEmpty()) {
|
||||
val title = if (albums.isEmpty()) "You might like" else ""
|
||||
ArtistsRow(title, artists, onArtistClick, onPlayArtist)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AlbumsRow(
|
||||
title: String,
|
||||
|
||||
@@ -105,6 +105,22 @@ class LibraryRepository @Inject constructor(
|
||||
suspend fun fetchArtistTracks(id: String): List<TrackRef> =
|
||||
api.getArtistTracks(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls related artists for the ArtistDetail "Similar artists"
|
||||
* strip. Network-only — non-critical UI, callers swallow failures
|
||||
* and render nothing rather than blocking the screen.
|
||||
*/
|
||||
suspend fun fetchSimilarArtists(id: String): List<ArtistRef> =
|
||||
api.getSimilarArtists(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls the artist's top tracks for the ArtistDetail "Top tracks"
|
||||
* panel. Network-only; tapping a row plays this exact list as a
|
||||
* queue, so no persistence is needed.
|
||||
*/
|
||||
suspend fun fetchArtistTopTracks(id: String): List<TrackRef> =
|
||||
api.getArtistTopTracks(id).map { it.toDomain() }
|
||||
|
||||
/**
|
||||
* Pulls the album detail (AlbumRef + tracks), persists both, and
|
||||
* returns the in-memory snapshot. Lets the AlbumDetail screen
|
||||
|
||||
+116
-13
@@ -1,3 +1,5 @@
|
||||
@file:Suppress("TooManyFunctions") // Compose screen + private helper composables
|
||||
|
||||
package com.fabledsword.minstrel.library.ui
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
@@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items as lazyItems
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -46,21 +49,27 @@ import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Play
|
||||
import com.composables.icons.lucide.User
|
||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.models.albumCoverPath
|
||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.shared.formatDuration
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||
import com.fabledsword.minstrel.shared.widgets.SkeletonAlbumTile
|
||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ArtistDetailScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: ArtistDetailViewModel = hiltViewModel(),
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
@@ -102,23 +111,35 @@ fun ArtistDetailScreen(
|
||||
title = "Couldn't load artist",
|
||||
body = s.message,
|
||||
)
|
||||
is ArtistDetailUiState.Success -> {
|
||||
val artistLiked by viewModel.artistLiked
|
||||
.collectAsStateWithLifecycle()
|
||||
ArtistBody(
|
||||
detail = s.detail,
|
||||
artistLiked = artistLiked,
|
||||
onPlay = viewModel::playArtist,
|
||||
onToggleLike = viewModel::toggleLikeArtist,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
)
|
||||
}
|
||||
is ArtistDetailUiState.Success ->
|
||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistSuccessBody(
|
||||
state: ArtistDetailUiState.Success,
|
||||
viewModel: ArtistDetailViewModel,
|
||||
playerViewModel: com.fabledsword.minstrel.player.ui.PlayerViewModel,
|
||||
navController: NavHostController,
|
||||
) {
|
||||
val artistLiked by viewModel.artistLiked.collectAsStateWithLifecycle()
|
||||
val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
|
||||
ArtistBody(
|
||||
state = state,
|
||||
artistLiked = artistLiked,
|
||||
playingTrackId = playerState.currentTrack?.id,
|
||||
onPlay = viewModel::playArtist,
|
||||
onToggleLike = viewModel::toggleLikeArtist,
|
||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||
onTopTrackClick = viewModel::playTopTracks,
|
||||
onSimilarArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
is ArtistDetailUiState.Success -> state.detail.artist.name
|
||||
is ArtistDetailUiState.Loading -> state.seed?.name ?: "Artist"
|
||||
@@ -127,12 +148,16 @@ private fun titleFor(state: ArtistDetailUiState): String = when (state) {
|
||||
|
||||
@Composable
|
||||
private fun ArtistBody(
|
||||
detail: ArtistDetailRef,
|
||||
state: ArtistDetailUiState.Success,
|
||||
artistLiked: Boolean,
|
||||
playingTrackId: String?,
|
||||
onPlay: () -> Unit,
|
||||
onToggleLike: () -> Unit,
|
||||
onAlbumClick: (String) -> Unit,
|
||||
onTopTrackClick: (Int) -> Unit,
|
||||
onSimilarArtistClick: (String) -> Unit,
|
||||
) {
|
||||
val detail = state.detail
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 176.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -149,6 +174,23 @@ private fun ArtistBody(
|
||||
onToggleLike = onToggleLike,
|
||||
)
|
||||
}
|
||||
if (state.topTracks.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
TopTracksPanel(
|
||||
tracks = state.topTracks,
|
||||
playingTrackId = playingTrackId,
|
||||
onTrackClick = onTopTrackClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.similarArtists.isNotEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
SimilarArtistsStrip(
|
||||
artists = state.similarArtists,
|
||||
onArtistClick = onSimilarArtistClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (detail.albums.isEmpty()) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
EmptyAlbumsHint()
|
||||
@@ -161,6 +203,67 @@ private fun ArtistBody(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopTracksPanel(
|
||||
tracks: List<TrackRef>,
|
||||
playingTrackId: String?,
|
||||
onTrackClick: (Int) -> Unit,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = "Top tracks",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
tracks.forEachIndexed { index, track ->
|
||||
TopTrackRow(
|
||||
track = track,
|
||||
nowPlaying = track.id == playingTrackId,
|
||||
onClick = { onTrackClick(index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TopTrackRow(
|
||||
track: TrackRef,
|
||||
nowPlaying: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
TrackRow(
|
||||
title = track.title,
|
||||
artist = track.artistName,
|
||||
trackId = track.id,
|
||||
onClick = onClick,
|
||||
nowPlaying = nowPlaying,
|
||||
trailing = {
|
||||
Text(
|
||||
text = formatDuration(track.durationSec, zero = "--:--"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = 12.dp),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SimilarArtistsStrip(
|
||||
artists: List<ArtistRef>,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
HorizontalScrollRow(title = "Similar artists") {
|
||||
lazyItems(items = artists, key = { it.id }) { artist ->
|
||||
ArtistCard(
|
||||
artist = artist,
|
||||
onClick = { onArtistClick(artist.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistHeader(
|
||||
artist: ArtistRef,
|
||||
|
||||
+49
-1
@@ -9,6 +9,7 @@ import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||
import com.fabledsword.minstrel.models.ArtistRef
|
||||
import com.fabledsword.minstrel.models.TrackRef
|
||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||
import com.fabledsword.minstrel.nav.DetailSeedCache
|
||||
import com.fabledsword.minstrel.player.PlayerController
|
||||
@@ -23,13 +24,18 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
||||
|
||||
sealed interface ArtistDetailUiState {
|
||||
data class Loading(val seed: ArtistRef? = null) : ArtistDetailUiState
|
||||
data class Success(val detail: ArtistDetailRef) : ArtistDetailUiState
|
||||
data class Success(
|
||||
val detail: ArtistDetailRef,
|
||||
val similarArtists: List<ArtistRef> = emptyList(),
|
||||
val topTracks: List<TrackRef> = emptyList(),
|
||||
) : ArtistDetailUiState
|
||||
data class Error(val message: String) : ArtistDetailUiState
|
||||
}
|
||||
|
||||
@@ -82,6 +88,7 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
try {
|
||||
val detail = repository.refreshArtistDetail(artistId)
|
||||
internal.value = ArtistDetailUiState.Success(detail)
|
||||
loadSecondarySections()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
@@ -89,6 +96,31 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the non-critical "Similar artists" + "Top tracks" sections
|
||||
* and folds them into the live Success state. Each failure is
|
||||
* swallowed (logged only) — these sections render absent rather than
|
||||
* blocking, so the core artist view never depends on them.
|
||||
*/
|
||||
private suspend fun loadSecondarySections() {
|
||||
val similar = sectionOrEmpty { repository.fetchSimilarArtists(artistId) }
|
||||
val top = sectionOrEmpty { repository.fetchArtistTopTracks(artistId) }
|
||||
val current = internal.value
|
||||
if (current is ArtistDetailUiState.Success) {
|
||||
internal.value = current.copy(similarArtists = similar, topTracks = top)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <T> sectionOrEmpty(block: () -> List<T>): List<T> =
|
||||
try {
|
||||
block()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||
) {
|
||||
Timber.w(e, "Artist secondary section fetch failed for %s", artistId)
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull every track across the artist's albums, shuffle, and start
|
||||
* playing. Mirrors Flutter's artist Play button — pressing Play on
|
||||
@@ -118,4 +150,20 @@ class ArtistDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the loaded "Top tracks" list starting at [index] — the top
|
||||
* tracks are themselves the playable queue (mirrors the web client's
|
||||
* sectionTracks = top-tracks behaviour). No network round-trip; the
|
||||
* list is already in the Success state.
|
||||
*/
|
||||
fun playTopTracks(index: Int) {
|
||||
val tracks = (internal.value as? ArtistDetailUiState.Success)?.topTracks.orEmpty()
|
||||
if (tracks.isEmpty()) return
|
||||
player.setQueue(
|
||||
tracks = tracks,
|
||||
initialIndex = index.coerceIn(0, tracks.lastIndex),
|
||||
source = "artist-top:$artistId",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
@file:OptIn(ExperimentalSerializationApi::class)
|
||||
|
||||
package com.fabledsword.minstrel.models.wire
|
||||
|
||||
import kotlinx.serialization.EncodeDefault
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -15,7 +19,11 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class PlayStartedRequest(
|
||||
val type: String = "play_started",
|
||||
// Discriminator MUST stay on the wire. The app's Json has
|
||||
// encodeDefaults=false, so without this a `type` left at its
|
||||
// default is omitted entirely and the server 400s the event as
|
||||
// "unknown event type" — silently dropping every native play.
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_started",
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
val source: String? = null,
|
||||
@@ -28,21 +36,21 @@ data class PlayStartedResponse(
|
||||
|
||||
@Serializable
|
||||
data class PlayEndedRequest(
|
||||
val type: String = "play_ended",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_ended",
|
||||
@SerialName("play_event_id") val playEventId: String,
|
||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlaySkippedRequest(
|
||||
val type: String = "play_skipped",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_skipped",
|
||||
@SerialName("play_event_id") val playEventId: String,
|
||||
@SerialName("position_ms") val positionMs: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PlayOfflineRequest(
|
||||
val type: String = "play_offline",
|
||||
@EncodeDefault(EncodeDefault.Mode.ALWAYS) val type: String = "play_offline",
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
val at: String,
|
||||
|
||||
@@ -10,13 +10,17 @@ import kotlinx.serialization.Serializable
|
||||
* `internal/api/types.go HomeIndexPayload`).
|
||||
*
|
||||
* Section name implies entity type; no per-entry type tag is needed:
|
||||
* - recentlyAddedAlbums → album
|
||||
* - rediscoverAlbums → album
|
||||
* - rediscoverArtists → artist
|
||||
* - mostPlayedTracks → track
|
||||
* - lastPlayedArtists → artist
|
||||
* - recentlyAddedAlbums → album
|
||||
* - rediscoverAlbums → album
|
||||
* - rediscoverArtists → artist
|
||||
* - mostPlayedTracks → track
|
||||
* - lastPlayedArtists → artist
|
||||
* - youMightLikeAlbums → album
|
||||
* - youMightLikeArtists → artist
|
||||
*
|
||||
* All slices default to empty so missing keys don't throw on decode.
|
||||
* All slices default to empty so missing keys don't throw on decode —
|
||||
* which also keeps the client safe against older servers that don't
|
||||
* emit the you-might-like sections yet.
|
||||
*/
|
||||
@Serializable
|
||||
data class HomeIndexWire(
|
||||
@@ -25,4 +29,6 @@ data class HomeIndexWire(
|
||||
@SerialName("rediscover_artists") val rediscoverArtists: List<String> = emptyList(),
|
||||
@SerialName("most_played_tracks") val mostPlayedTracks: List<String> = emptyList(),
|
||||
@SerialName("last_played_artists") val lastPlayedArtists: List<String> = emptyList(),
|
||||
@SerialName("you_might_like_albums") val youMightLikeAlbums: List<String> = emptyList(),
|
||||
@SerialName("you_might_like_artists") val youMightLikeArtists: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import android.content.Context
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* Holds a WiFi + CPU wake lock for the lifetime of an active UPnP cast.
|
||||
*
|
||||
* While casting, the wrapped ExoPlayer is paused, so its automatic
|
||||
* `WAKE_MODE_NETWORK` locks are released and nothing keeps the phone's radio
|
||||
* awake. On a locked, on-battery phone the WiFi enters power-save within
|
||||
* seconds and the CPU dozes; that stalls the 1 Hz liveness poll to the
|
||||
* renderer and the queue-extend calls to the server. The poll then trips the
|
||||
* drop threshold and playback falls back to a phone that *also* has no
|
||||
* network — silence, while the renderer was streaming fine the whole time.
|
||||
*
|
||||
* A high-performance / low-latency [WifiManager.WifiLock] keeps the radio out
|
||||
* of power-save, and a partial [PowerManager.WakeLock] keeps the poll
|
||||
* coroutine scheduled. Both are acquired when a route goes active and released
|
||||
* the moment it drops or the user switches back to the phone.
|
||||
*
|
||||
* All methods are idempotent (`setReferenceCounted(false)` + held-checks) and
|
||||
* synchronized, so they are safe to call from any thread.
|
||||
*/
|
||||
class CastNetworkLock(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
private val wifiLock: WifiManager.WifiLock? =
|
||||
(appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)
|
||||
?.createWifiLock(wifiLockMode(), LOCK_TAG)
|
||||
?.apply { setReferenceCounted(false) }
|
||||
|
||||
private val wakeLock: PowerManager.WakeLock? =
|
||||
(appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager)
|
||||
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG)
|
||||
?.apply { setReferenceCounted(false) }
|
||||
|
||||
@Synchronized
|
||||
fun acquire() {
|
||||
runCatching { wifiLock?.takeUnless { it.isHeld }?.acquire() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wifi acquire failed") }
|
||||
runCatching { wakeLock?.takeUnless { it.isHeld }?.acquire() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wake acquire failed") }
|
||||
Timber.w(
|
||||
"CastNetworkLock acquired (wifi=%s wake=%s)",
|
||||
wifiLock?.isHeld, wakeLock?.isHeld,
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun release() {
|
||||
runCatching { wifiLock?.takeIf { it.isHeld }?.release() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wifi release failed") }
|
||||
runCatching { wakeLock?.takeIf { it.isHeld }?.release() }
|
||||
.onFailure { Timber.w(it, "CastNetworkLock: wake release failed") }
|
||||
Timber.w("CastNetworkLock released")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val LOCK_TAG = "minstrel:upnp-cast"
|
||||
|
||||
// WIFI_MODE_FULL_LOW_LATENCY (API 29+) both disables power-save and
|
||||
// lowers latency; FULL_HIGH_PERF is the pre-Q equivalent for keeping
|
||||
// the radio fully awake.
|
||||
fun wifiLockMode(): Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||
}
|
||||
}
|
||||
}
|
||||
+148
-25
@@ -10,14 +10,19 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.media3.common.ForwardingPlayer
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import java.io.IOException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.isActive
|
||||
@@ -39,9 +44,15 @@ import timber.log.Timber
|
||||
* wrapped ExoPlayer stays paused at the position it had when the
|
||||
* route was selected.
|
||||
*
|
||||
* Drop heuristic: 3 consecutive poll failures fire [onDrop]. The
|
||||
* factory wraps that callback into a SharedFlow consumed by the
|
||||
* NowPlaying surface as a snackbar.
|
||||
* Drop heuristic: the 1 Hz poll loop is the *sole* arbiter of route
|
||||
* liveness -- [RemotePlayerState.recordPollFailure]'s rolling threshold
|
||||
* (DROP_THRESHOLD consecutive failures) fires [onDrop]. A failed transport
|
||||
* command (play/pause/seek/next) does NOT drop on its own: a locked phone's
|
||||
* WiFi power-save can stall a single command's socket I/O for a second or
|
||||
* two while the renderer is perfectly reachable, so commands retry on
|
||||
* transient IO failure and otherwise defer to the poll loop. The factory
|
||||
* wraps the [onDrop] callback into a SharedFlow consumed by the NowPlaying
|
||||
* surface as a snackbar.
|
||||
*
|
||||
* Queue mode: OutputPickerController loads the full queue into Sonos's
|
||||
* native queue via ClearQueue + AddURIToQueue, then points the
|
||||
@@ -54,6 +65,8 @@ class MinstrelForwardingPlayer(
|
||||
private val delegate: Player,
|
||||
private val holder: ActiveUpnpHolder,
|
||||
private val remoteState: RemotePlayerState,
|
||||
private val castNetworkLock: CastNetworkLock,
|
||||
private val networkStatus: NetworkStatusController,
|
||||
private val onDrop: (routeName: String) -> Unit,
|
||||
) : ForwardingPlayer(delegate) {
|
||||
|
||||
@@ -107,6 +120,20 @@ class MinstrelForwardingPlayer(
|
||||
scope.launch {
|
||||
holder.active.collect { active -> onActiveChanged(active) }
|
||||
}
|
||||
scope.launch {
|
||||
// When the phone's own network recovers, reconcile a held cast
|
||||
// session immediately instead of waiting up to POLL_INTERVAL_MS:
|
||||
// the renderer kept playing, so one round-trip re-confirms it and
|
||||
// resyncs the cursor/position. Only a transition *into* Healthy
|
||||
// while a route is active matters.
|
||||
var prev = networkStatus.state.value
|
||||
networkStatus.state.collect { next ->
|
||||
if (next == ServerHealth.Healthy && prev != ServerHealth.Healthy && isRemote()) {
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
prev = next
|
||||
}
|
||||
}
|
||||
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
||||
// addObserver requires it. The observer just trySend's to the channel.
|
||||
handler.post {
|
||||
@@ -172,13 +199,14 @@ class MinstrelForwardingPlayer(
|
||||
if (active == null) {
|
||||
super.play()
|
||||
} else {
|
||||
remoteState.setPlayIntent(true)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.play() }
|
||||
runCatching { retryTransport { active.avTransport.play() } }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPlaying()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,13 +221,14 @@ class MinstrelForwardingPlayer(
|
||||
if (active == null) {
|
||||
super.pause()
|
||||
} else {
|
||||
remoteState.setPlayIntent(false)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.pause() }
|
||||
runCatching { retryTransport { active.avTransport.pause() } }
|
||||
.onSuccess {
|
||||
remoteState.applyTransportPaused()
|
||||
notifyRemoteStateChanged()
|
||||
}
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,8 +251,8 @@ class MinstrelForwardingPlayer(
|
||||
trackNumber = remoteState.trackNumber,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.seek(positionMs) }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.seek(positionMs) } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,11 +282,13 @@ class MinstrelForwardingPlayer(
|
||||
)
|
||||
scope.launch {
|
||||
runCatching {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
retryTransport {
|
||||
active.avTransport.seekToTrack(mediaItemIndex + 1)
|
||||
if (positionMs > 0L) {
|
||||
active.avTransport.seek(positionMs)
|
||||
}
|
||||
}
|
||||
}.onFailure { handleSoapFailure(active, it) }
|
||||
}.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,8 +311,8 @@ class MinstrelForwardingPlayer(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.next() }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.next() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,8 +334,8 @@ class MinstrelForwardingPlayer(
|
||||
SystemClock.elapsedRealtime() + PENDING_TRANSPORT_SAFETY_TIMEOUT_MS,
|
||||
)
|
||||
scope.launch {
|
||||
runCatching { active.avTransport.previous() }
|
||||
.onFailure { handleSoapFailure(active, it) }
|
||||
runCatching { retryTransport { active.avTransport.previous() } }
|
||||
.onFailure { handleTransportFailure(active, it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,11 +416,34 @@ class MinstrelForwardingPlayer(
|
||||
super.release()
|
||||
}
|
||||
|
||||
private fun handleSoapFailure(active: ActiveUpnp, t: Throwable) {
|
||||
pollJob?.cancel()
|
||||
Timber.w(t, "UPnP transport call failed on %s", active.routeName)
|
||||
remoteState.applyError(t)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
/**
|
||||
* Run a transport SOAP block, retrying transport-level (IO) failures a
|
||||
* few times with backoff. A locked phone's WiFi power-save can stall the
|
||||
* first socket I/O for a second or two; a retry lets the command land once
|
||||
* WiFi wakes instead of being abandoned. A [SoapFaultException] (the
|
||||
* renderer answered and rejected the action) is NOT retried -- the device
|
||||
* is alive and retrying won't change its verdict.
|
||||
*/
|
||||
private suspend fun <T> retryTransport(block: suspend () -> T): T =
|
||||
retryTransientIo(TRANSPORT_RETRY_ATTEMPTS, TRANSPORT_RETRY_BACKOFF_MS, block)
|
||||
|
||||
/**
|
||||
* A transport SOAP command failed even after retries. Deliberately does
|
||||
* NOT declare the route dropped: the 1 Hz poll loop is the single arbiter
|
||||
* of liveness (DROP_THRESHOLD consecutive poll failures). A locked phone's
|
||||
* WiFi power-save can fail one command while the renderer is perfectly
|
||||
* reachable; dropping on a single command falsely kicked playback back to
|
||||
* the phone. We log, leave the poll loop running, and nudge an immediate
|
||||
* poll so the UI reconciles to Sonos's actual state -- if the renderer is
|
||||
* truly gone, the poll loop trips the drop on its own.
|
||||
*/
|
||||
private fun handleTransportFailure(active: ActiveUpnp, t: Throwable) {
|
||||
if (t is SoapFaultException) {
|
||||
Timber.w(t, "UPnP transport rejected by %s -- device alive, no drop", active.routeName)
|
||||
} else {
|
||||
Timber.w(t, "UPnP transport failed on %s -- poll loop arbitrates", active.routeName)
|
||||
}
|
||||
pollTrigger.trySend(Unit)
|
||||
}
|
||||
|
||||
private fun onActiveChanged(active: ActiveUpnp?) {
|
||||
@@ -402,6 +456,11 @@ class MinstrelForwardingPlayer(
|
||||
lastNotifiedTrackIdx = -1
|
||||
if (active != null) {
|
||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||
// Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
|
||||
// about to be paused (releasing its WAKE_MODE_NETWORK locks), so
|
||||
// without this the radio power-saves on a locked screen and the
|
||||
// poll below starves -- see [CastNetworkLock].
|
||||
castNetworkLock.acquire()
|
||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||
// simultaneously with the remote renderer. handler.post targets the
|
||||
// application looper, so this runs on the same thread that processes
|
||||
@@ -411,20 +470,26 @@ class MinstrelForwardingPlayer(
|
||||
handler.post { delegate.pause() }
|
||||
pollJob = scope.launch { pollLoop(active) }
|
||||
} else {
|
||||
castNetworkLock.release()
|
||||
remoteState.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||
var networkDropSuppressed = false
|
||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||
val outcome = runCatching { pollOnce(active) }
|
||||
if (outcome.isSuccess) {
|
||||
remoteState.recordPollSuccess()
|
||||
networkDropSuppressed = false
|
||||
} else if (remoteState.recordPollFailure()) {
|
||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
return
|
||||
if (networkStatus.state.value == ServerHealth.Healthy) {
|
||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||
handler.post { onDrop(active.routeName) }
|
||||
return
|
||||
}
|
||||
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
|
||||
}
|
||||
// Race the normal cadence against any external wake (activity
|
||||
// resume). Whichever wins continues to the next pollOnce.
|
||||
@@ -435,6 +500,30 @@ class MinstrelForwardingPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The poll-failure threshold tripped, but the phone's *own* network is down
|
||||
* (state is not [ServerHealth.Healthy]) -- so this is "we can't see the
|
||||
* renderer right now," not "the renderer died." A UPnP renderer streams
|
||||
* autonomously and is almost certainly still playing; meanwhile a local
|
||||
* fallback couldn't reach the server either, so dropping would only blast
|
||||
* local audio out of a pocketed phone while the speaker keeps going. Hold
|
||||
* the route and keep polling -- a successful poll once the network returns
|
||||
* reconciles to the renderer's real state. Clear the failure streak so
|
||||
* recovery re-evaluates the renderer from scratch instead of re-dropping on
|
||||
* the first post-recovery hiccup. Returns the (latched) suppression flag so
|
||||
* the rationale logs once per outage, not every tick.
|
||||
*/
|
||||
private fun suppressDropForNetwork(active: ActiveUpnp, alreadySuppressed: Boolean): Boolean {
|
||||
if (!alreadySuppressed) {
|
||||
Timber.w(
|
||||
"UPnP drop suppressed for %s -- phone network %s, holding route",
|
||||
active.routeName, networkStatus.state.value,
|
||||
)
|
||||
}
|
||||
remoteState.recordPollSuccess() // clear streak; not a renderer failure
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll tick: read position + transport state from Sonos, apply to
|
||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||
@@ -513,5 +602,39 @@ class MinstrelForwardingPlayer(
|
||||
// when Sonos's reported Track matches the wrapped player; this only
|
||||
// kicks in if SOAP fails or Sonos drops the ack entirely.
|
||||
const val PENDING_TRANSPORT_SAFETY_TIMEOUT_MS = 5_000L
|
||||
// Transport commands retry transient IO failures so a single WiFi
|
||||
// power-save stall (locked phone) doesn't abandon the command. 3
|
||||
// attempts x the SoapClient's 2s connect timeout + backoff bounds the
|
||||
// worst case at ~7s; a still-failing command then defers to the poll.
|
||||
const val TRANSPORT_RETRY_ATTEMPTS = 3
|
||||
const val TRANSPORT_RETRY_BACKOFF_MS = 400L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry [block] on transient transport-level ([IOException]) failures, with
|
||||
* [backoffMs] between attempts, up to [attempts] total. Anything that is not
|
||||
* an [IOException] -- notably [SoapFaultException], where the renderer
|
||||
* answered and rejected the action -- propagates immediately: the device is
|
||||
* alive, so retrying won't change its verdict. Extracted from
|
||||
* [MinstrelForwardingPlayer] so a locked phone's WiFi power-save stall doesn't
|
||||
* abandon a single transport command (the bug that falsely reverted Sonos
|
||||
* playback to local audio).
|
||||
*/
|
||||
internal suspend fun <T> retryTransientIo(
|
||||
attempts: Int,
|
||||
backoffMs: Long,
|
||||
block: suspend () -> T,
|
||||
): T {
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
try {
|
||||
return block()
|
||||
} catch (io: IOException) {
|
||||
attempt += 1
|
||||
if (attempt >= attempts) throw io
|
||||
Timber.w(io, "transient transport failure (attempt %d) -- retrying", attempt)
|
||||
delay(backoffMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||
import com.fabledsword.minstrel.auth.AuthStore
|
||||
import com.fabledsword.minstrel.cache.mutations.MutationQueue
|
||||
import com.fabledsword.minstrel.cache.mutations.PlayEndedPayload
|
||||
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
|
||||
import com.fabledsword.minstrel.di.ApplicationScope
|
||||
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
|
||||
@@ -64,11 +65,24 @@ class PlayEventsReporter @Inject constructor(
|
||||
private var curLastPositionMs: Long = 0
|
||||
private var curDurationMs: Long = 0
|
||||
|
||||
// Whether the currently-tracked play is actively playing (vs paused).
|
||||
// onStop reads this: a backgrounded play that's still playing is left
|
||||
// to the live path (the foreground service keeps us alive), while a
|
||||
// paused one is durably closed so a kill can't orphan its open row.
|
||||
private var curPlaying: Boolean = false
|
||||
|
||||
// Server play_event_id when the live play_started succeeded;
|
||||
// null if start failed/offline — then the close is captured into
|
||||
// the offline mutation queue instead of a live ended/skipped.
|
||||
private var openPlayEventId: String? = null
|
||||
|
||||
// True from beginTrack until startLive's play_started resolves. When a
|
||||
// close races an in-flight start (id still null), the server row is
|
||||
// being created but we've lost its id — we must NOT also enqueue an
|
||||
// offline play (that would duplicate); the server auto-closes the
|
||||
// orphan on the next play_started instead.
|
||||
private var startInFlight: Boolean = false
|
||||
|
||||
private var prevTrackId: String? = null
|
||||
|
||||
init {
|
||||
@@ -95,7 +109,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
) {
|
||||
// Track-change → close the prior tracked play.
|
||||
if (trackId != prevTrackId && curTrackId != null) {
|
||||
closeCurrent(viaOffline = false)
|
||||
closeCurrent()
|
||||
}
|
||||
|
||||
// Entered playing for a new track → begin tracking it.
|
||||
@@ -110,6 +124,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
if (curTrackId != null && trackId == curTrackId) {
|
||||
curLastPositionMs = positionMs
|
||||
if (durationMs > 0) curDurationMs = durationMs
|
||||
curPlaying = playing
|
||||
}
|
||||
|
||||
prevTrackId = trackId
|
||||
@@ -121,12 +136,18 @@ class PlayEventsReporter @Inject constructor(
|
||||
curSource = source
|
||||
curLastPositionMs = 0
|
||||
curDurationMs = durationMs
|
||||
curPlaying = true
|
||||
openPlayEventId = null
|
||||
startInFlight = true
|
||||
startLive(trackId = trackId, source = source)
|
||||
}
|
||||
|
||||
private fun startLive(trackId: String, source: String?) {
|
||||
val cid = resolveClientId() ?: return
|
||||
val cid = resolveClientId()
|
||||
if (cid == null) {
|
||||
startInFlight = false
|
||||
return
|
||||
}
|
||||
scope.launch {
|
||||
try {
|
||||
val response = api.playStarted(
|
||||
@@ -144,56 +165,72 @@ class PlayEventsReporter @Inject constructor(
|
||||
) {
|
||||
// Offline / flaky — openPlayEventId stays null; close
|
||||
// path will enqueue the completed play for replay.
|
||||
} finally {
|
||||
startInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeCurrent(viaOffline: Boolean) {
|
||||
/**
|
||||
* Closes the tracked play. Routes by whether the server already has an
|
||||
* open row, NOT by caller context — this is what keeps background /
|
||||
* track-change / failure paths from each minting a second row:
|
||||
* - open server row (id) → close it by id; on failure enqueue a durable
|
||||
* close-by-id (never an offline play).
|
||||
* - start in flight (no id yet) → leave it; the server auto-closes the
|
||||
* orphan on the next play_started. Enqueuing offline here would
|
||||
* duplicate the in-flight row.
|
||||
* - no server row at all → record the whole play offline.
|
||||
*
|
||||
* Always uses the actual position played; the server's skip rule
|
||||
* (50%/30s defaults) classifies was_skipped from it.
|
||||
*/
|
||||
private fun closeCurrent() {
|
||||
val trackId = curTrackId
|
||||
val startedAt = curStartedAt
|
||||
if (trackId == null || startedAt == null) {
|
||||
resetCurrent()
|
||||
return
|
||||
}
|
||||
// Always send the actual position played to; the server's
|
||||
// skip rule (50%/30s defaults, configurable) classifies
|
||||
// was_skipped from this. If the track ran to completion the
|
||||
// last observed position is ~= duration, which gives ratio ~1
|
||||
// and the server records a non-skip. The COMPLETION_TOLERANCE
|
||||
// rounding the prior shape did is no longer needed — the
|
||||
// server's threshold is the source of truth.
|
||||
val durationMs = curLastPositionMs
|
||||
val source = curSource
|
||||
val id = openPlayEventId
|
||||
|
||||
if (!viaOffline && id != null) {
|
||||
scope.launch {
|
||||
try {
|
||||
api.playEnded(
|
||||
PlayEndedRequest(
|
||||
playEventId = id,
|
||||
durationPlayedMs = durationMs,
|
||||
),
|
||||
)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
val inFlight = startInFlight
|
||||
when {
|
||||
id != null -> closeById(id, durationMs)
|
||||
inFlight -> Unit
|
||||
else -> enqueueOffline(trackId, startedAt, source, durationMs)
|
||||
}
|
||||
resetCurrent()
|
||||
}
|
||||
|
||||
private fun closeById(id: String, durationMs: Long) {
|
||||
scope.launch {
|
||||
try {
|
||||
api.playEnded(
|
||||
PlayEndedRequest(playEventId = id, durationPlayedMs = durationMs),
|
||||
)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Durable close-by-id (idempotent server-side) — survives a
|
||||
// process kill without orphaning the open row or duplicating.
|
||||
mutationQueue.enqueuePlayEnded(
|
||||
PlayEndedPayload(playEventId = id, durationPlayedMs = durationMs),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetCurrent() {
|
||||
curTrackId = null
|
||||
curStartedAt = null
|
||||
curSource = null
|
||||
curLastPositionMs = 0
|
||||
curDurationMs = 0
|
||||
curPlaying = false
|
||||
openPlayEventId = null
|
||||
startInFlight = false
|
||||
}
|
||||
|
||||
private fun enqueueOffline(
|
||||
@@ -232,8 +269,17 @@ class PlayEventsReporter @Inject constructor(
|
||||
// ── Lifecycle: durable-close on app background / detach ────────
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (curTrackId != null) {
|
||||
closeCurrent(viaOffline = true)
|
||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||
// alone: the foreground media service keeps the process — and this
|
||||
// app-scope uiState collector — alive, so the live state machine
|
||||
// closes the play accurately on the next real track change. Closing
|
||||
// here (the old always-offline behavior) raced the collector's
|
||||
// re-begin and minted a duplicate, skip-misclassified row on every
|
||||
// screen-lock. Only durably close a PAUSED play, where no further
|
||||
// progress will happen and a process kill would otherwise orphan the
|
||||
// open server row. See docs/superpowers/2026-06-11 contract audit.
|
||||
if (curTrackId != null && !curPlaying) {
|
||||
closeCurrent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,20 @@ class PlayerController @Inject constructor(
|
||||
controller.play()
|
||||
}
|
||||
|
||||
/**
|
||||
* Align the local cursor to [index] + [positionMs] WITHOUT auto-playing.
|
||||
* Used by the UPnP adopt path: when attaching to a renderer that's already
|
||||
* playing our queue, we sync the local cursor to the renderer's current
|
||||
* track/position so skip/seek and the UI map onto its live session — the
|
||||
* renderer keeps the audio, the local player stays paused. Unlike
|
||||
* [seekToIndex] this issues no play().
|
||||
*/
|
||||
fun moveCursorTo(index: Int, positionMs: Long) {
|
||||
val controller = mediaController ?: return
|
||||
if (index !in queueRefs.indices) return
|
||||
runOnControllerThread(controller) { controller.seekTo(index, positionMs) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the queue with [tracks] starting at [initialIndex].
|
||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
||||
|
||||
@@ -81,6 +81,8 @@ class PlayerFactory @Inject constructor(
|
||||
delegate = exo,
|
||||
holder = activeUpnpHolder,
|
||||
remoteState = remoteState,
|
||||
castNetworkLock = CastNetworkLock(context),
|
||||
networkStatus = serverHealth,
|
||||
onDrop = { name -> emitDrop(name) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import javax.inject.Singleton
|
||||
* Updates flow in from:
|
||||
* - 1Hz GetPositionInfo poll -> applyPositionInfo()
|
||||
* - Transport SOAP calls landing 200 OK -> applyTransport{Playing,Paused,Stopped}()
|
||||
* - Error paths -> applyError() (drop fallback) or recordPollFailure()
|
||||
* - Poll failures -> recordPollFailure()
|
||||
*
|
||||
* The poll-failure counter implements the rolling-3 drop heuristic: 3
|
||||
* The poll-failure counter is the sole drop arbiter: [DROP_THRESHOLD]
|
||||
* consecutive poll failures = remote considered dropped (returns true
|
||||
* from recordPollFailure for the caller to surface). Success resets it.
|
||||
* from recordPollFailure for the caller to surface). Any success resets it.
|
||||
* Failed transport commands no longer drop the route -- they retry and
|
||||
* otherwise defer to this counter (see MinstrelForwardingPlayer).
|
||||
*/
|
||||
@Singleton
|
||||
class RemotePlayerState @Inject constructor() {
|
||||
@@ -27,6 +29,13 @@ class RemotePlayerState @Inject constructor() {
|
||||
@Volatile var lastError: Throwable? = null; private set
|
||||
@Volatile var trackNumber: Int = 0; private set
|
||||
|
||||
// The operator's last explicit transport intent (play=true, pause=false),
|
||||
// distinct from the observed remote isPlaying. handleRemoteDrop resumes
|
||||
// local playback based on THIS, not isPlaying -- so a play() that fails
|
||||
// its SOAP (stale/dead renderer) still resumes on the phone instead of
|
||||
// swallowing the tap. Deliberately NOT cleared by applyError.
|
||||
@Volatile var lastPlayIntent: Boolean = false; private set
|
||||
|
||||
// Pending-transport deadline (SystemClock.elapsedRealtime() at which we
|
||||
// give up waiting). When > 0, a user transport action (next/prev/seekTo
|
||||
// idx) is in flight: ForwardingPlayer has already moved the wrapped
|
||||
@@ -63,6 +72,8 @@ class RemotePlayerState @Inject constructor() {
|
||||
positionMs = 0L
|
||||
}
|
||||
|
||||
fun setPlayIntent(intended: Boolean) { lastPlayIntent = intended }
|
||||
|
||||
fun applyError(t: Throwable) {
|
||||
isPlaying = false
|
||||
lastError = t
|
||||
@@ -80,6 +91,7 @@ class RemotePlayerState @Inject constructor() {
|
||||
positionMs = 0L
|
||||
durationMs = 0L
|
||||
isPlaying = false
|
||||
lastPlayIntent = false
|
||||
currentTrackUri = ""
|
||||
lastError = null
|
||||
consecutivePollFailures = 0
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
/** Action the idle-revert collector should take on a player-state change. */
|
||||
internal enum class IdleRevertAction { ARM, CANCEL, IGNORE }
|
||||
|
||||
/**
|
||||
* Pure decision for the UPnP idle-revert timer. [armed] is whether the
|
||||
* idle timer Job is currently running. We ARM only on the transition INTO
|
||||
* "engaged + not playing" (so repeated paused emissions don't reset the
|
||||
* countdown), CANCEL whenever playback resumes or UPnP disengages, and
|
||||
* otherwise IGNORE.
|
||||
*/
|
||||
internal fun idleRevertAction(
|
||||
upnpEngaged: Boolean,
|
||||
isPlaying: Boolean,
|
||||
armed: Boolean,
|
||||
): IdleRevertAction = when {
|
||||
upnpEngaged && !isPlaying -> if (armed) IdleRevertAction.IGNORE else IdleRevertAction.ARM
|
||||
armed -> IdleRevertAction.CANCEL
|
||||
else -> IdleRevertAction.IGNORE
|
||||
}
|
||||
+202
-5
@@ -15,10 +15,12 @@ import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
@@ -147,6 +150,12 @@ class OutputPickerController @Inject constructor(
|
||||
// can run the longest-common-prefix / common-suffix diff.
|
||||
private var lastSyncedQueueIds: List<String>? = null
|
||||
|
||||
// Idle-revert timer. Armed when playback is paused/stopped on a UPnP
|
||||
// route; fires revertToPhoneOnIdle after IDLE_REVERT_MS of continuous
|
||||
// non-playing. Cancelled on resume, route change, or explicit disconnect.
|
||||
// See idleRevertAction for the arm/cancel decision.
|
||||
private var idleRevertJob: Job? = null
|
||||
|
||||
init {
|
||||
// Two-arg addCallback registers with no discovery flag —
|
||||
// androidx.mediarouter 1.7.0's default passive behavior:
|
||||
@@ -155,7 +164,8 @@ class OutputPickerController @Inject constructor(
|
||||
// CALLBACK_FLAG_PASSIVE_DISCOVERY constant; absent flag = passive.)
|
||||
mediaRouter.addCallback(selector, callback)
|
||||
// When MinstrelForwardingPlayer reports the active UPnP route has
|
||||
// dropped (3+ consecutive poll failures or a transport SOAP exception),
|
||||
// dropped (the poll loop's consecutive-failure threshold tripping --
|
||||
// the sole drop arbiter; a failed transport command no longer drops),
|
||||
// clear the UPnP selection state and fall back to local ExoPlayer at
|
||||
// the last-known remote position. This mirrors selectSystem's disconnect
|
||||
// path but skips the Stop SOAP since the device is already unreachable.
|
||||
@@ -163,6 +173,79 @@ class OutputPickerController @Inject constructor(
|
||||
playerFactory.dropEvents.collect { handleRemoteDrop() }
|
||||
}
|
||||
scope.launch { observeQueueChangesForSonosResync() }
|
||||
scope.launch { observeIdleRevertWhileUpnp() }
|
||||
scope.launch { observeSelectedRouteDisappearance() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert the UPnP selection to the phone when the selected renderer leaves
|
||||
* discovery (powered off / off the LAN) while we are NOT actively casting
|
||||
* to it. Without this the selection could pin to a ghost route, so a later
|
||||
* "play" would target a speaker that's gone. An *active* cast is left alone
|
||||
* -- its own poll loop arbitrates liveness and won't drop on a mere
|
||||
* discovery gap. Routes leave discovery via [UpnpDiscoveryController]'s
|
||||
* post-picker-scan prune; this reacts to the resulting routes change.
|
||||
*/
|
||||
private suspend fun observeSelectedRouteDisappearance() {
|
||||
upnpDiscovery.routes.collect { routes ->
|
||||
val selected = selectedUpnpRouteIdInternal.value
|
||||
val present = routes.any { it.id == selected }
|
||||
val casting = activeUpnpHolder.active.value?.routeId == selected
|
||||
if (selected != null && !present && !casting) {
|
||||
Timber.w(
|
||||
"Output: selected UPnP route %s left discovery; reverting to phone",
|
||||
selected,
|
||||
)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Arm/cancel the idle-revert timer as playback state changes while a
|
||||
* UPnP route is engaged. "Engaged" = active OR target set, so the brief
|
||||
* selectUpnp/resync window (active momentarily null, target set) doesn't
|
||||
* spuriously fire. The arm/cancel choice is the pure idleRevertAction.
|
||||
*/
|
||||
private suspend fun observeIdleRevertWhileUpnp() {
|
||||
playerController.uiState.collect { state ->
|
||||
val engaged = activeUpnpHolder.active.value != null ||
|
||||
activeUpnpHolder.target.value != null
|
||||
val armed = idleRevertJob?.isActive == true
|
||||
when (idleRevertAction(engaged, state.isPlaying, armed)) {
|
||||
IdleRevertAction.ARM ->
|
||||
idleRevertJob = scope.launch {
|
||||
delay(IDLE_REVERT_MS)
|
||||
revertToPhoneOnIdle()
|
||||
}
|
||||
IdleRevertAction.CANCEL -> {
|
||||
idleRevertJob?.cancel()
|
||||
idleRevertJob = null
|
||||
}
|
||||
IdleRevertAction.IGNORE -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer fired: the operator paused on a UPnP route and never resumed.
|
||||
* Stop the renderer (best-effort -- it's reachable, unlike a drop),
|
||||
* clear the UPnP overlay so routesState falls back to the phone, and
|
||||
* seek local ExoPlayer to the last remote position. No play(): the
|
||||
* operator was paused, so we revert routing silently. Guarded by the
|
||||
* selectUpnpMutex so it can't race selectUpnp/resync.
|
||||
*/
|
||||
private suspend fun revertToPhoneOnIdle() = selectUpnpMutex.withLock {
|
||||
if (remoteState.isPlaying) return@withLock // resumed at the boundary
|
||||
val active = activeUpnpHolder.active.value
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
runCatching { active?.avTransport?.stop() }
|
||||
.onFailure { Timber.w(it, "UPnP Stop failed during idle revert") }
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
idleRevertJob = null
|
||||
playerController.seekTo(capturedPositionMs)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -365,16 +448,19 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the active UPnP route drops unexpectedly (poll-failure
|
||||
* threshold or SOAP exception). Captures the last remote position +
|
||||
* play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||
* Called when the active UPnP route drops unexpectedly (the poll loop's
|
||||
* consecutive-failure threshold tripping). Captures the last remote
|
||||
* position + play state, clears UPnP selection, and resumes local ExoPlayer at
|
||||
* the same point. Skips the Stop SOAP (device already unreachable).
|
||||
* The snackbar is handled independently by the NowPlaying surface
|
||||
* collecting the same [PlayerFactory.dropEvents] via PlayerController.
|
||||
*/
|
||||
private fun handleRemoteDrop() {
|
||||
val capturedPositionMs = remoteState.positionMs
|
||||
val wasPlayingRemote = remoteState.isPlaying
|
||||
// Resume on the operator's last play-intent, not the observed remote
|
||||
// state: after a pause, isPlaying is false, but a failed play() that
|
||||
// triggered this drop means the operator DID ask to play -- honor it.
|
||||
val wasPlayingRemote = remoteState.lastPlayIntent
|
||||
activeUpnpHolder.set(null)
|
||||
activeUpnpHolder.setTarget(null)
|
||||
selectedUpnpRouteIdInternal.value = null
|
||||
@@ -482,6 +568,13 @@ class OutputPickerController @Inject constructor(
|
||||
// local audio while we queue up Sonos.
|
||||
playerController.pause()
|
||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||
// If the renderer is already playing this exact queue (a "stray"
|
||||
// session we got disconnected from), attach to it in place instead of
|
||||
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
||||
// live queue. Falls through to the clear+reload path when it isn't ours.
|
||||
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
||||
return@withLock
|
||||
}
|
||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||
// silently while target is set but active is null -- the user's premature
|
||||
// taps don't hit Sonos's stale state from a prior session.
|
||||
@@ -497,6 +590,9 @@ class OutputPickerController @Inject constructor(
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
// Selecting a route loads the queue and Plays on the renderer, so
|
||||
// the standing intent is "play" -- a later drop should resume local.
|
||||
remoteState.setPlayIntent(true)
|
||||
}.onFailure { e ->
|
||||
Timber.w(e, "UPnP select failed for route ${effectiveRoute.id}")
|
||||
activeUpnpHolder.set(null)
|
||||
@@ -505,6 +601,102 @@ class OutputPickerController @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer's current position + transport state, validated as an
|
||||
* adoptable continuation of our local queue: same track id at the same
|
||||
* 1-based index. Null means "not ours / not playing" -> caller reloads.
|
||||
*/
|
||||
private data class AdoptPlan(
|
||||
val localIndex: Int,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long,
|
||||
val trackUri: String,
|
||||
val trackNumber: Int,
|
||||
val playing: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Attach to a renderer that is *already* playing our queue (a session the
|
||||
* phone got disconnected from but the speaker kept streaming) instead of
|
||||
* clearing + reloading it. Probes the renderer's live state, and if it's
|
||||
* mid-playback on the same track id at the same queue index, syncs the
|
||||
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
|
||||
* skips a beat, and skip/seek now drive its live queue. Returns false (and
|
||||
* the caller does the full clear+reload) when the renderer isn't playing
|
||||
* our queue. The local player is already paused by the caller.
|
||||
*/
|
||||
private suspend fun tryAdoptRunningSession(
|
||||
transport: AVTransportClient,
|
||||
rendering: RenderingControlClient?,
|
||||
route: OutputRoute,
|
||||
localQueue: List<TrackRef>,
|
||||
): Boolean {
|
||||
val plan = probeAdoptable(transport, route, localQueue) ?: return false
|
||||
Timber.w(
|
||||
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
||||
route.name, plan.trackNumber, plan.positionMs,
|
||||
)
|
||||
// active is still null here, so this aligns the *local* cursor without
|
||||
// emitting SOAP; the holder.set below then starts the poll loop, which
|
||||
// reconciles to the renderer's advancing position from here.
|
||||
playerController.moveCursorTo(plan.localIndex, plan.positionMs)
|
||||
remoteState.applyPositionInfo(
|
||||
positionMs = plan.positionMs,
|
||||
durationMs = plan.durationMs,
|
||||
trackUri = plan.trackUri,
|
||||
trackNumber = plan.trackNumber,
|
||||
)
|
||||
remoteState.setPlayIntent(plan.playing)
|
||||
activeUpnpHolder.set(
|
||||
ActiveUpnp(
|
||||
routeId = route.id,
|
||||
routeName = route.name,
|
||||
avTransport = transport,
|
||||
rendering = rendering,
|
||||
),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun probeAdoptable(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
localQueue: List<TrackRef>,
|
||||
): AdoptPlan? {
|
||||
val probe = runCatching {
|
||||
transport.getTransportInfo() to transport.getPositionInfo()
|
||||
}.getOrElse {
|
||||
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
|
||||
return null
|
||||
}
|
||||
val (info, pos) = probe
|
||||
val idx0 = pos.track - 1
|
||||
val playingId = trackIdFromStreamUrl(pos.trackUri)
|
||||
val midPlayback =
|
||||
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
||||
val aligned = midPlayback && playingId != null &&
|
||||
idx0 in localQueue.indices && localQueue[idx0].id == playingId
|
||||
return if (aligned) {
|
||||
AdoptPlan(
|
||||
localIndex = idx0,
|
||||
positionMs = pos.relTimeMs,
|
||||
durationMs = pos.trackDurationMs,
|
||||
trackUri = pos.trackUri,
|
||||
trackNumber = pos.track,
|
||||
playing = info.state == TransportState.PLAYING,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
|
||||
private fun trackIdFromStreamUrl(url: String): String? {
|
||||
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
|
||||
val i = segments.indexOf("tracks")
|
||||
return if (i >= 0) segments.getOrNull(i + 1) else null
|
||||
}
|
||||
|
||||
private suspend fun loadQueueOnSonos(
|
||||
transport: AVTransportClient,
|
||||
route: OutputRoute,
|
||||
@@ -640,5 +832,10 @@ class OutputPickerController @Inject constructor(
|
||||
private companion object {
|
||||
const val EXTEND_ABORT_AFTER_FAILURES = 3
|
||||
const val EXTEND_THROTTLE_MS = 50L
|
||||
|
||||
// 5 minutes of continuous non-playing on a UPnP route before we
|
||||
// revert to the phone speaker, so a stale Sonos selection can't make
|
||||
// a later "tap play" do nothing.
|
||||
const val IDLE_REVERT_MS = 5 * 60 * 1000L
|
||||
}
|
||||
}
|
||||
|
||||
+55
-2
@@ -8,6 +8,7 @@ import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -17,6 +18,8 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -45,9 +48,24 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
) {
|
||||
private val ssdp = SsdpDiscovery(context)
|
||||
|
||||
// Transport commands (play/pause/seek/next) and the 1 Hz poll go through
|
||||
// this client. A dead renderer should fail fast so OutputPickerController's
|
||||
// drop-recovery reverts to the phone in ~2 s instead of the shared client's
|
||||
// 10 s connect timeout. Derived via newBuilder() so the connection pool +
|
||||
// dispatcher stay shared with the app client.
|
||||
private val controlHttp: OkHttpClient = okHttp.newBuilder()
|
||||
.connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||
|
||||
// Wall-clock of the last NOTIFY / M-SEARCH response per route id. Drives
|
||||
// post-active-scan pruning so a powered-off renderer leaves the picker
|
||||
// (and can't pin a stale selection) instead of lingering for the process
|
||||
// lifetime -- upsert only ever added before.
|
||||
private val lastSeenMs = ConcurrentHashMap<String, Long>()
|
||||
|
||||
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||
|
||||
@@ -68,7 +86,31 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
* that just opened).
|
||||
*/
|
||||
fun upgradeDiscovery() {
|
||||
appScope.launch { ssdp.requestActiveScan() }
|
||||
appScope.launch {
|
||||
val scanStartMs = System.currentTimeMillis()
|
||||
ssdp.requestActiveScan()
|
||||
// Give present devices time to answer the M-SEARCH (+ our follow-up
|
||||
// description fetch), then drop any route that didn't re-announce.
|
||||
// Pruning is tied to picker-open scans only -- no background timer,
|
||||
// so route rows never flicker while the user isn't looking.
|
||||
delay(ACTIVE_SCAN_PRUNE_DELAY_MS)
|
||||
pruneRoutesNotSeenSince(scanStartMs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop routes whose last NOTIFY / M-SEARCH response predates [cutoffMs]
|
||||
* -- i.e. they didn't answer the active scan that just ran, so they're
|
||||
* gone from the LAN. Called only after [upgradeDiscovery]'s scan window.
|
||||
*/
|
||||
private fun pruneRoutesNotSeenSince(cutoffMs: Long) {
|
||||
val before = routesInternal.value
|
||||
val survivors = before.filter { (lastSeenMs[it.id] ?: 0L) >= cutoffMs }
|
||||
if (survivors.size == before.size) return
|
||||
val removed = before.filterNot { it in survivors }.map { it.id }
|
||||
removed.forEach { lastSeenMs.remove(it) }
|
||||
Timber.w("UPnP discovery: pruned %d stale route(s): %s", removed.size, removed)
|
||||
routesInternal.value = survivors
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,7 +133,7 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
fun transportFor(routeId: String): AVTransportClient? {
|
||||
val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
|
||||
return AVTransportClient(
|
||||
loggingSoapClient(okHttp, route.name),
|
||||
loggingSoapClient(controlHttp, route.name),
|
||||
route.avTransportControlUrl,
|
||||
)
|
||||
}
|
||||
@@ -105,6 +147,7 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
}
|
||||
|
||||
private fun upsertRoute(route: UpnpRoute) {
|
||||
lastSeenMs[route.id] = System.currentTimeMillis()
|
||||
val current = routesInternal.value
|
||||
val idx = current.indexOfFirst { it.id == route.id }
|
||||
routesInternal.value = if (idx < 0) {
|
||||
@@ -225,6 +268,16 @@ class UpnpDiscoveryController @Inject constructor(
|
||||
// devices append. Anchored to end-of-string so it never eats
|
||||
// a legitimate parenthetical inside a name.
|
||||
val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")
|
||||
|
||||
// Aggressive connect timeout for transport SOAP so a dead renderer
|
||||
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
||||
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
||||
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
||||
|
||||
// Grace window after an M-SEARCH burst before pruning non-responders.
|
||||
// Covers the SSDP round-trip plus our per-device description fetch;
|
||||
// generous enough that a present-but-slow renderer isn't false-pruned.
|
||||
const val ACTIVE_SCAN_PRUNE_DELAY_MS = 4_000L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-1
@@ -5,11 +5,13 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.events.EventsStream
|
||||
import com.fabledsword.minstrel.models.RequestRef
|
||||
import com.fabledsword.minstrel.models.RequestStatus
|
||||
import com.fabledsword.minstrel.requests.data.CancelOutcome
|
||||
import com.fabledsword.minstrel.requests.data.RequestsRepository
|
||||
import com.fabledsword.minstrel.shared.UiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -20,6 +22,10 @@ import javax.inject.Inject
|
||||
|
||||
private val RELEVANT_EVENT_KINDS = setOf("request.status_changed")
|
||||
|
||||
// Cadence for in-flight request polling — matches the web client (#369):
|
||||
// fast enough to feel live, slow enough not to hammer the server.
|
||||
private const val POLL_INTERVAL_MS = 12_000L
|
||||
|
||||
@HiltViewModel
|
||||
class RequestsViewModel @Inject constructor(
|
||||
private val repository: RequestsRepository,
|
||||
@@ -31,11 +37,14 @@ class RequestsViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
refresh()
|
||||
// SSE push: reconciler completions arrive here; reload silently so the
|
||||
// row updates in place rather than flashing the loading spinner.
|
||||
viewModelScope.launch {
|
||||
eventsStream.events
|
||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||
.collect { refresh() }
|
||||
.collect { silentReload() }
|
||||
}
|
||||
viewModelScope.launch { pollWhileInFlight() }
|
||||
}
|
||||
|
||||
fun refresh(): Job = viewModelScope.launch {
|
||||
@@ -54,6 +63,43 @@ class RequestsViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-poll while any request is mid-ingest (status APPROVED), matching the
|
||||
* web client (#369). Reloads silently every [POLL_INTERVAL_MS] and pauses
|
||||
* when nothing is in-flight — complements the SSE push so progress stays
|
||||
* live even if the event stream drops.
|
||||
*/
|
||||
private suspend fun pollWhileInFlight() {
|
||||
while (true) {
|
||||
if (hasInFlightRequest()) {
|
||||
silentReload()
|
||||
}
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasInFlightRequest(): Boolean {
|
||||
val state = internal.value
|
||||
return state is UiState.Success &&
|
||||
state.data.any { it.status == RequestStatus.APPROVED }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the list in place without flipping to Loading — used by the poll
|
||||
* loop and the SSE collector so visible rows update without a spinner
|
||||
* flash. A transient failure keeps the current view; the next reload wins.
|
||||
*/
|
||||
private suspend fun silentReload() {
|
||||
try {
|
||||
val rows = repository.listMine()
|
||||
internal.value = if (rows.isEmpty()) UiState.Empty else UiState.Success(rows)
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||
) {
|
||||
// Keep the current view; the next poll / SSE / pull reconciles.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistically removes [id] from the success list so the row
|
||||
* disappears immediately; on server-side success the row is
|
||||
|
||||
@@ -69,6 +69,32 @@ class RemotePlayerStateTest {
|
||||
assertFalse(state.recordPollFailure())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play intent survives a SOAP error so a failed play can resume locally`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.applyError(RuntimeException("connect timeout"))
|
||||
// isPlaying is cleared by the error, but the operator's intent persists.
|
||||
assertFalse(state.isPlaying)
|
||||
assertTrue(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pausing clears play intent`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.setPlayIntent(false)
|
||||
assertFalse(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset clears play intent`() {
|
||||
val state = RemotePlayerState()
|
||||
state.setPlayIntent(true)
|
||||
state.reset()
|
||||
assertFalse(state.lastPlayIntent)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DROP_THRESHOLD = 30
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.fabledsword.minstrel.player
|
||||
|
||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.IOException
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
/**
|
||||
* Locks in the transport-command retry semantics behind the locked-phone
|
||||
* Sonos drop fix: a transient IO stall (WiFi power-save) must be retried, a
|
||||
* SOAP fault from a responding renderer must NOT be retried, and an
|
||||
* exhausted retry must rethrow rather than loop forever. Without this, a
|
||||
* single failed command falsely reverted Sonos playback to the phone.
|
||||
*/
|
||||
class RetryTransientIoTest {
|
||||
|
||||
@Test
|
||||
fun `returns immediately on first success`() = runTest {
|
||||
var calls = 0
|
||||
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
"ok"
|
||||
}
|
||||
assertEquals("ok", result)
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `retries transient IO failure then succeeds`() = runTest {
|
||||
var calls = 0
|
||||
val result = retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
if (calls < 2) throw IOException("connect timeout")
|
||||
"ok"
|
||||
}
|
||||
assertEquals("ok", result)
|
||||
assertEquals(2, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rethrows IO failure after exhausting attempts`() = runTest {
|
||||
var calls = 0
|
||||
assertFailsWith<IOException> {
|
||||
retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
throw IOException("still asleep")
|
||||
}
|
||||
}
|
||||
assertEquals(3, calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not retry a SOAP fault from a responding renderer`() = runTest {
|
||||
var calls = 0
|
||||
assertFailsWith<SoapFaultException> {
|
||||
retryTransientIo(attempts = 3, backoffMs = 0L) {
|
||||
calls += 1
|
||||
throw SoapFaultException("718", "Invalid InstanceID")
|
||||
}
|
||||
}
|
||||
assertEquals(1, calls)
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.fabledsword.minstrel.player.output
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class IdleRevertDecisionTest {
|
||||
|
||||
@Test
|
||||
fun `engaged and paused and not armed arms the timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.ARM,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `engaged and paused and already armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = false, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resuming playback cancels an armed timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.CANCEL,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playing with no timer armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = true, isPlaying = true, armed = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disengaging UPnP cancels an armed timer`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.CANCEL,
|
||||
idleRevertAction(upnpEngaged = false, isPlaying = false, armed = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disengaged with no timer armed is ignored`() {
|
||||
assertEquals(
|
||||
IdleRevertAction.IGNORE,
|
||||
idleRevertAction(upnpEngaged = false, isPlaying = true, armed = false),
|
||||
)
|
||||
}
|
||||
}
|
||||
+48
-4
@@ -5,12 +5,15 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
@@ -53,6 +56,35 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// safetyNetScanInterval backstops the fsnotify watcher with a low-frequency
|
||||
// full delta walk, catching anything the watcher missed (inotify watch-limit
|
||||
// exhaustion, dropped events). Fixed — there is no operator configuration.
|
||||
const safetyNetScanInterval = 12 * time.Hour
|
||||
|
||||
// runSafetyNetScans ticks a delta RunScan at safetyNetScanInterval until ctx
|
||||
// is cancelled, deferring to TryStartScan's in-flight guard so it never
|
||||
// collides with a manual, startup, or watcher-driven scan.
|
||||
func runSafetyNetScans(ctx context.Context, pool *pgxpool.Pool, scanner *library.Scanner,
|
||||
enricher *coverart.Enricher, logger *slog.Logger, scanCfg library.RunScanConfig,
|
||||
) {
|
||||
ticker := time.NewTicker(safetyNetScanInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
started, existing, err := library.TryStartScan(ctx, pool, scanner, enricher, logger, scanCfg)
|
||||
if err != nil {
|
||||
logger.Error("safety-net scan: try start failed", "err", err)
|
||||
} else if !started && existing != nil {
|
||||
logger.Info("safety-net scan skipped — prior still in flight",
|
||||
"in_flight_id", existing.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
configPath := flag.String("config", os.Getenv("MINSTREL_CONFIG"), "path to YAML config file")
|
||||
flag.Parse()
|
||||
@@ -237,12 +269,24 @@ func run() error {
|
||||
ArtistEnrichCap: -1,
|
||||
DataDir: cfg.Storage.DataDir,
|
||||
}
|
||||
scheduler := library.NewScheduler(pool, logger.With("component", "scheduler"),
|
||||
scanner, coverEnricher, scanCfg)
|
||||
scheduler.Start(ctx)
|
||||
// Filesystem watcher: near-instant pickup of new/changed files via
|
||||
// fsnotify, scanning just the affected paths + enriching their albums
|
||||
// inline. Replaces the removed configurable scan scheduler.
|
||||
watcher := library.NewWatcher(scanner, coverEnricher,
|
||||
logger.With("component", "watcher"), cfg.Library.ScanPaths)
|
||||
go func() {
|
||||
if werr := watcher.Run(ctx); werr != nil {
|
||||
logger.Warn("library watcher exited", "err", werr)
|
||||
}
|
||||
}()
|
||||
// Safety-net delta walk backstops the watcher (inotify limits / dropped
|
||||
// events) at a fixed low frequency. No operator config.
|
||||
go runSafetyNetScans(ctx, pool, scanner, coverEnricher,
|
||||
logger.With("component", "scan_safetynet"), scanCfg)
|
||||
|
||||
srv := server.New(logger, pool, scanner, subsonic.Config{
|
||||
AllowPlaintextPassword: cfg.Subsonic.AllowPlaintextPassword,
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg, scheduler)
|
||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||||
srv.Bus = bus
|
||||
srv.PlaylistScheduler = playlistScheduler
|
||||
srv.StreamSecret = cfg.StreamSecret
|
||||
|
||||
@@ -4,6 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
@@ -25,5 +26,6 @@ require (
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
)
|
||||
|
||||
@@ -23,6 +23,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
type scanScheduleResp struct {
|
||||
Mode string `json:"mode"`
|
||||
IntervalHours *int `json:"interval_hours"`
|
||||
TimeOfDay *string `json:"time_of_day"`
|
||||
WeeklyDay *int `json:"weekly_day"`
|
||||
NextScheduledAt *string `json:"next_scheduled_at"`
|
||||
}
|
||||
|
||||
type scanScheduleReq struct {
|
||||
Mode string `json:"mode"`
|
||||
IntervalHours *int `json:"interval_hours"`
|
||||
TimeOfDay *string `json:"time_of_day"`
|
||||
WeeklyDay *int `json:"weekly_day"`
|
||||
}
|
||||
|
||||
func (h *handlers) handleGetScanSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
q := dbq.New(h.pool)
|
||||
row, err := q.GetScanSchedule(r.Context())
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
h.logger.Error("admin: scan_schedule singleton row missing")
|
||||
writeErr(w, apierror.InternalMsg("schedule row missing", errors.New("schedule row missing")))
|
||||
return
|
||||
}
|
||||
writeErrWithLog(w, h.logger, "admin: get scan schedule", apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, h.scheduler.NextFire()))
|
||||
}
|
||||
|
||||
func (h *handlers) handlePatchScanSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
var req scanScheduleReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateScheduleReq(&req); err != nil {
|
||||
writeErr(w, apierror.BadRequest("validation", err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
q := dbq.New(h.pool)
|
||||
if err := q.UpdateScanSchedule(r.Context(), dbq.UpdateScanScheduleParams{
|
||||
Mode: req.Mode,
|
||||
IntervalHours: pgInt32Ptr(req.IntervalHours),
|
||||
TimeOfDay: req.TimeOfDay,
|
||||
WeeklyDay: pgInt32Ptr(req.WeeklyDay),
|
||||
}); err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: update scan schedule", apierror.InternalMsg("update failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
h.scheduler.Refresh()
|
||||
|
||||
row, err := q.GetScanSchedule(r.Context())
|
||||
if err != nil {
|
||||
writeErrWithLog(w, h.logger, "admin: re-read scan schedule", apierror.InternalMsg("re-read failed", err))
|
||||
return
|
||||
}
|
||||
cfg := library.ScheduleConfig{Mode: row.Mode}
|
||||
if row.IntervalHours != nil {
|
||||
cfg.IntervalHours = int(*row.IntervalHours)
|
||||
}
|
||||
if row.TimeOfDay != nil {
|
||||
cfg.TimeOfDay = *row.TimeOfDay
|
||||
}
|
||||
if row.WeeklyDay != nil {
|
||||
cfg.WeeklyDay = int(*row.WeeklyDay)
|
||||
}
|
||||
next := cfg.NextFire(time.Now())
|
||||
|
||||
writeJSON(w, http.StatusOK, scanScheduleRespFromRow(row, next))
|
||||
}
|
||||
|
||||
func validateScheduleReq(req *scanScheduleReq) error {
|
||||
switch req.Mode {
|
||||
case "off":
|
||||
req.IntervalHours = nil
|
||||
req.TimeOfDay = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "interval":
|
||||
if req.IntervalHours == nil || *req.IntervalHours <= 0 {
|
||||
return fmt.Errorf("interval_hours required and > 0 when mode=interval")
|
||||
}
|
||||
req.TimeOfDay = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "daily":
|
||||
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
|
||||
return fmt.Errorf("time_of_day required when mode=daily")
|
||||
}
|
||||
req.IntervalHours = nil
|
||||
req.WeeklyDay = nil
|
||||
return nil
|
||||
case "weekly":
|
||||
if req.TimeOfDay == nil || *req.TimeOfDay == "" {
|
||||
return fmt.Errorf("time_of_day required when mode=weekly")
|
||||
}
|
||||
if req.WeeklyDay == nil || *req.WeeklyDay < 1 || *req.WeeklyDay > 7 {
|
||||
return fmt.Errorf("weekly_day required and in [1, 7] when mode=weekly")
|
||||
}
|
||||
req.IntervalHours = nil
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid mode %q (want off / interval / daily / weekly)", req.Mode)
|
||||
}
|
||||
}
|
||||
|
||||
func scanScheduleRespFromRow(row dbq.GetScanScheduleRow, nextFire time.Time) scanScheduleResp {
|
||||
resp := scanScheduleResp{Mode: row.Mode}
|
||||
if row.IntervalHours != nil {
|
||||
v := int(*row.IntervalHours)
|
||||
resp.IntervalHours = &v
|
||||
}
|
||||
if row.TimeOfDay != nil {
|
||||
v := *row.TimeOfDay
|
||||
resp.TimeOfDay = &v
|
||||
}
|
||||
if row.WeeklyDay != nil {
|
||||
v := int(*row.WeeklyDay)
|
||||
resp.WeeklyDay = &v
|
||||
}
|
||||
if !nextFire.IsZero() {
|
||||
s := nextFire.UTC().Format(time.RFC3339)
|
||||
resp.NextScheduledAt = &s
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func pgInt32Ptr(src *int) *int32 {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
v := int32(*src)
|
||||
return &v
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
|
||||
)
|
||||
|
||||
func newAdminScheduleRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin())
|
||||
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
||||
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Get_DefaultIsOff(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedget", "pw", true)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp scanScheduleResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Mode != "off" {
|
||||
t.Errorf("Mode = %q, want off", resp.Mode)
|
||||
}
|
||||
if resp.NextScheduledAt != nil {
|
||||
t.Errorf("NextScheduledAt = %v, want nil for mode=off", resp.NextScheduledAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_DailyMode(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedpatch", "pw", true)
|
||||
|
||||
body := `{"mode":"daily","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var resp scanScheduleResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Mode != "daily" {
|
||||
t.Errorf("Mode = %q, want daily", resp.Mode)
|
||||
}
|
||||
if resp.TimeOfDay == nil || *resp.TimeOfDay != "03:00" {
|
||||
t.Errorf("TimeOfDay = %v, want '03:00'", resp.TimeOfDay)
|
||||
}
|
||||
if resp.NextScheduledAt == nil {
|
||||
t.Errorf("NextScheduledAt = nil, want a future ISO timestamp")
|
||||
} else {
|
||||
next, err := time.Parse(time.RFC3339, *resp.NextScheduledAt)
|
||||
if err != nil {
|
||||
t.Errorf("NextScheduledAt parse: %v", err)
|
||||
} else if local := next.Local(); local.Hour() != 3 || local.Minute() != 0 {
|
||||
// Handler computes next-fire from time.Now() in the host zone, so
|
||||
// the local-time hour is what's stable across CI runners regardless
|
||||
// of TZ; the wire timestamp is UTC but parses back to the same
|
||||
// instant either way.
|
||||
t.Errorf("NextScheduledAt local time = %v, want 03:00 in host zone", local)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_WeeklyMissingDay_400(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedweekly", "pw", true)
|
||||
|
||||
body := `{"mode":"weekly","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_Patch_OffNormalizes(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
admin := seedUser(t, pool, "schedoff", "pw", true)
|
||||
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`UPDATE scan_schedule SET mode='daily', time_of_day='03:00' WHERE id=true`,
|
||||
); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
body := `{"mode":"off","time_of_day":"03:00"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/admin/scan/schedule",
|
||||
bytes.NewReader([]byte(body)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = withUser(req, admin)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
var timeOfDay *string
|
||||
if err := pool.QueryRow(context.Background(),
|
||||
`SELECT time_of_day FROM scan_schedule WHERE id=true`,
|
||||
).Scan(&timeOfDay); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if timeOfDay != nil {
|
||||
t.Errorf("time_of_day = %v, want nil after mode=off normalize", timeOfDay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminScanSchedule_NonAdmin_403(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
s := library.NewScheduler(pool, h.logger, nil, nil, library.RunScanConfig{})
|
||||
h.scheduler = s
|
||||
user := seedUser(t, pool, "schednonadmin", "pw", false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/scan/schedule", nil)
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newAdminScheduleRouter(h).ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -28,7 +28,7 @@ import (
|
||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||
// RequireUser; everything else is gated by the middleware. The events writer
|
||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||
h := &handlers{
|
||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||
@@ -42,7 +42,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
coverSettings: coverSettings,
|
||||
scanner: scanner,
|
||||
scanCfg: scanCfg,
|
||||
scheduler: scheduler,
|
||||
dataDir: dataDir,
|
||||
mailer: sender,
|
||||
eventbus: bus,
|
||||
@@ -75,6 +74,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Post("/auth/logout", h.handleLogout)
|
||||
authed.Get("/me", h.handleGetMe)
|
||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
||||
authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
||||
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
||||
authed.Get("/me/history", h.handleGetMyHistory)
|
||||
@@ -87,6 +87,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
authed.Get("/artists", h.handleListArtists)
|
||||
authed.Get("/artists/{id}", h.handleGetArtist)
|
||||
authed.Get("/artists/{id}/tracks", h.handleGetArtistTracks)
|
||||
authed.Get("/artists/{id}/similar", h.handleGetSimilarArtists)
|
||||
authed.Get("/artists/{id}/top-tracks", h.handleGetArtistTopTracks)
|
||||
authed.Get("/albums/{id}", h.handleGetAlbum)
|
||||
authed.Get("/albums/{id}/cover", h.handleGetCover)
|
||||
authed.Get("/library/shuffle", h.handleLibraryShuffle)
|
||||
@@ -167,8 +169,6 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
|
||||
admin.Get("/scan/status", h.handleGetScanStatus)
|
||||
admin.Post("/scan/run", h.handleTriggerScan)
|
||||
admin.Get("/scan/schedule", h.handleGetScanSchedule)
|
||||
admin.Patch("/scan/schedule", h.handlePatchScanSchedule)
|
||||
|
||||
admin.Get("/library/coverage", h.handleGetLibraryCoverage)
|
||||
|
||||
@@ -222,7 +222,6 @@ type handlers struct {
|
||||
coverSettings *coverart.SettingsService
|
||||
scanner *library.Scanner
|
||||
scanCfg library.RunScanConfig
|
||||
scheduler *library.Scheduler
|
||||
dataDir string
|
||||
mailer mailer.Sender
|
||||
eventbus *eventbus.Bus
|
||||
|
||||
@@ -30,6 +30,8 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
|
||||
RediscoverArtists: make([]ArtistRef, 0, len(data.RediscoverArtists)),
|
||||
MostPlayedTracks: make([]TrackRef, 0, len(data.MostPlayedTracks)),
|
||||
LastPlayedArtists: make([]ArtistRef, 0, len(data.LastPlayedArtists)),
|
||||
YouMightLikeAlbums: make([]AlbumRef, 0, len(data.YouMightLikeAlbums)),
|
||||
YouMightLikeArtists: make([]ArtistRef, 0, len(data.YouMightLikeArtists)),
|
||||
}
|
||||
for _, row := range data.RecentlyAddedAlbums {
|
||||
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||
@@ -46,6 +48,12 @@ func (h *handlers) handleGetHome(w http.ResponseWriter, r *http.Request) {
|
||||
for _, row := range data.LastPlayedArtists {
|
||||
out.LastPlayedArtists = append(out.LastPlayedArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
||||
}
|
||||
for _, row := range data.YouMightLikeAlbums {
|
||||
out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, albumRefFrom(row.Album, row.ArtistName, 0, 0))
|
||||
}
|
||||
for _, row := range data.YouMightLikeArtists {
|
||||
out.YouMightLikeArtists = append(out.YouMightLikeArtists, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -78,6 +86,8 @@ func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) {
|
||||
RediscoverArtists: make([]string, 0, len(data.RediscoverArtists)),
|
||||
MostPlayedTracks: make([]string, 0, len(data.MostPlayedTracks)),
|
||||
LastPlayedArtists: make([]string, 0, len(data.LastPlayedArtists)),
|
||||
YouMightLikeAlbums: make([]string, 0, len(data.YouMightLikeAlbums)),
|
||||
YouMightLikeArtists: make([]string, 0, len(data.YouMightLikeArtists)),
|
||||
}
|
||||
for _, row := range data.RecentlyAddedAlbums {
|
||||
out.RecentlyAddedAlbums = append(out.RecentlyAddedAlbums, uuidToString(row.Album.ID))
|
||||
@@ -94,6 +104,12 @@ func (h *handlers) handleGetHomeIndex(w http.ResponseWriter, r *http.Request) {
|
||||
for _, row := range data.LastPlayedArtists {
|
||||
out.LastPlayedArtists = append(out.LastPlayedArtists, uuidToString(row.Artist.ID))
|
||||
}
|
||||
for _, row := range data.YouMightLikeAlbums {
|
||||
out.YouMightLikeAlbums = append(out.YouMightLikeAlbums, uuidToString(row.Album.ID))
|
||||
}
|
||||
for _, row := range data.YouMightLikeArtists {
|
||||
out.YouMightLikeArtists = append(out.YouMightLikeArtists, uuidToString(row.Artist.ID))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
|
||||
@@ -159,6 +159,85 @@ func (h *handlers) handleGetArtistTracks(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// similarArtistsDefaultLimit caps the artist-detail "similar artists" strip;
|
||||
// artistTopTracksDefaultLimit caps the per-user "top tracks" panel.
|
||||
const (
|
||||
similarArtistsDefaultLimit = 12
|
||||
artistTopTracksDefaultLimit = 5
|
||||
)
|
||||
|
||||
// handleGetSimilarArtists implements GET /api/artists/{id}/similar. Returns
|
||||
// in-library artists similar to {id} (ranked by similarity score) as a flat
|
||||
// ArtistRef list with cover + album count. Empty when the similarity ingest
|
||||
// has no matches yet; 404 when the artist doesn't exist.
|
||||
func (h *handlers) handleGetSimilarArtists(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, apierror.NotFound("artist"))
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get artist for similar", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
rows, err := q.ListSimilarArtistsForArtist(r.Context(), dbq.ListSimilarArtistsForArtistParams{
|
||||
SeedArtistID: id, ResultLimit: similarArtistsDefaultLimit,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list similar artists", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]ArtistRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, artistRefFromCovered(row.Artist, int(row.AlbumCount), row.CoverAlbumID))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleGetArtistTopTracks implements GET /api/artists/{id}/top-tracks. Returns
|
||||
// the current user's most-played tracks for {id} (skips excluded, quarantine
|
||||
// filtered). Empty when the user hasn't played this artist; 404 when the
|
||||
// artist doesn't exist.
|
||||
func (h *handlers) handleGetArtistTopTracks(w http.ResponseWriter, r *http.Request) {
|
||||
id, ok := requireURLUUID(w, r, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
user, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
q := dbq.New(h.pool)
|
||||
if _, err := q.GetArtistByID(r.Context(), id); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, apierror.NotFound("artist"))
|
||||
return
|
||||
}
|
||||
h.logger.Error("api: get artist for top tracks", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
rows, err := q.ListMostPlayedTracksForArtist(r.Context(), dbq.ListMostPlayedTracksForArtistParams{
|
||||
ArtistID: id, UserID: user.ID, ResultLimit: artistTopTracksDefaultLimit,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("api: list artist top tracks", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
out := make([]TrackRef, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleLibraryShuffle implements GET /api/library/shuffle?limit=N —
|
||||
// the online source for the client's always-present "Shuffle all"
|
||||
// (#427 S4). N random tracks across the whole library, per-user
|
||||
|
||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
||||
r := chi.NewRouter()
|
||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
30*time.Minute, 0.5, 30000)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, nil, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||
|
||||
paths := []string{
|
||||
"/api/artists",
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
const (
|
||||
recMetricsDefaultDays = 30
|
||||
recMetricsMaxDays = 365
|
||||
)
|
||||
|
||||
// recommendationMetric is one recommendation surface's outcomes.
|
||||
type recommendationMetric struct {
|
||||
Source string `json:"source"` // 'for_you' | 'discover' | mixes
|
||||
Plays int64 `json:"plays"` // plays launched from this surface
|
||||
Skips int64 `json:"skips"` // of those, marked skipped
|
||||
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||
}
|
||||
|
||||
type recommendationMetricsResp struct {
|
||||
WindowDays int `json:"window_days"`
|
||||
Sources []recommendationMetric `json:"sources"`
|
||||
}
|
||||
|
||||
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
|
||||
// caller over the last `days` (default 30, capped at 365), so the operator can
|
||||
// see which recommendation surfaces are landing and tune the taste weights.
|
||||
// Only plays tagged with a system-playlist source count; library/radio plays
|
||||
// (no source) are excluded.
|
||||
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
days, ok := parseMetricsDays(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := dbq.New(h.pool).RecommendationSourceMetricsForUser(r.Context(),
|
||||
dbq.RecommendationSourceMetricsForUserParams{UserID: caller.ID, Column2: float64(days)})
|
||||
if err != nil {
|
||||
h.logger.Error("api: recommendation metrics", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||
return
|
||||
}
|
||||
|
||||
out := recommendationMetricsResp{
|
||||
WindowDays: days,
|
||||
Sources: make([]recommendationMetric, 0, len(rows)),
|
||||
}
|
||||
for _, row := range rows {
|
||||
source := ""
|
||||
if row.Source != nil {
|
||||
source = *row.Source
|
||||
}
|
||||
var skipRate float64
|
||||
if row.Plays > 0 {
|
||||
skipRate = float64(row.Skips) / float64(row.Plays)
|
||||
}
|
||||
out.Sources = append(out.Sources, recommendationMetric{
|
||||
Source: source,
|
||||
Plays: row.Plays,
|
||||
Skips: row.Skips,
|
||||
SkipRate: skipRate,
|
||||
AvgCompletion: row.AvgCompletion,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
||||
// Writes a 400 and returns ok=false on a malformed value.
|
||||
func parseMetricsDays(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||
v := r.URL.Query().Get("days")
|
||||
if v == "" {
|
||||
return recMetricsDefaultDays, true
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n < 1 {
|
||||
writeErr(w, apierror.BadRequest("bad_request", "invalid days"))
|
||||
return 0, false
|
||||
}
|
||||
if n > recMetricsMaxDays {
|
||||
n = recMetricsMaxDays
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func newMetricsRouter(h *handlers) chi.Router {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/api/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||
return r
|
||||
}
|
||||
|
||||
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
||||
// skip flag. A nil source inserts NULL (library/radio play).
|
||||
func seedSourcedPlay(
|
||||
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
||||
source *string, completion float64, skipped bool,
|
||||
) {
|
||||
t.Helper()
|
||||
if _, err := h.pool.Exec(context.Background(),
|
||||
`INSERT INTO play_events
|
||||
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
|
||||
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
|
||||
userID, trackID, sessionID, source, completion, skipped); err != nil {
|
||||
t.Fatalf("seed sourced play: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationMetrics_NoSession401(t *testing.T) {
|
||||
h, _ := testHandlers(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
newMetricsRouter(h).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
h, pool := testHandlers(t)
|
||||
user := seedUser(t, pool, "metrics", "pw", false)
|
||||
artist := seedArtist(t, pool, "MetricArtist")
|
||||
album := seedAlbum(t, pool, artist.ID, "MetricAlbum", 2020)
|
||||
tk := seedTrack(t, pool, album.ID, artist.ID, "MetricTrack", 1, 200000)
|
||||
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||
|
||||
forYou := "for_you"
|
||||
discover := "discover"
|
||||
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
|
||||
// discover: 1 play.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
|
||||
// library play (NULL source) — must be excluded.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||
req = withUser(req, user)
|
||||
rec := httptest.NewRecorder()
|
||||
newMetricsRouter(h).ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
|
||||
var resp recommendationMetricsResp
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.WindowDays != recMetricsDefaultDays {
|
||||
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
||||
}
|
||||
bySource := map[string]recommendationMetric{}
|
||||
for _, m := range resp.Sources {
|
||||
bySource[m.Source] = m
|
||||
}
|
||||
if _, present := bySource[""]; present {
|
||||
t.Error("NULL-source (library) plays should be excluded")
|
||||
}
|
||||
fy, ok := bySource["for_you"]
|
||||
if !ok {
|
||||
t.Fatal("for_you metrics missing")
|
||||
}
|
||||
if fy.Plays != 3 || fy.Skips != 1 {
|
||||
t.Errorf("for_you plays/skips = %d/%d, want 3/1", fy.Plays, fy.Skips)
|
||||
}
|
||||
if fy.SkipRate < 0.32 || fy.SkipRate > 0.34 {
|
||||
t.Errorf("for_you skip_rate = %.3f, want ~0.333", fy.SkipRate)
|
||||
}
|
||||
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
||||
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
||||
}
|
||||
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
|
||||
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,7 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
JitterMagnitude: h.recCfg.JitterMagnitude,
|
||||
ContextWeight: h.recCfg.ContextWeight,
|
||||
SimilarityWeight: h.recCfg.SimilarityWeight,
|
||||
TasteWeight: h.recCfg.TasteWeight,
|
||||
}
|
||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||
|
||||
|
||||
@@ -116,6 +116,11 @@ type HomePayload struct {
|
||||
RediscoverArtists []ArtistRef `json:"rediscover_artists"`
|
||||
MostPlayedTracks []TrackRef `json:"most_played_tracks"`
|
||||
LastPlayedArtists []ArtistRef `json:"last_played_artists"`
|
||||
// You-might-like: in-library albums/artists predicted from the user's
|
||||
// listening that they don't actively spin. Built daily; gated on a
|
||||
// minimum listening history so a thin profile gets empty rows.
|
||||
YouMightLikeAlbums []AlbumRef `json:"you_might_like_albums"`
|
||||
YouMightLikeArtists []ArtistRef `json:"you_might_like_artists"`
|
||||
}
|
||||
|
||||
// HomeIndexPayload is the response body of GET /api/home/index — the
|
||||
@@ -134,6 +139,8 @@ type HomeIndexPayload struct {
|
||||
RediscoverArtists []string `json:"rediscover_artists"`
|
||||
MostPlayedTracks []string `json:"most_played_tracks"`
|
||||
LastPlayedArtists []string `json:"last_played_artists"`
|
||||
YouMightLikeAlbums []string `json:"you_might_like_albums"`
|
||||
YouMightLikeArtists []string `json:"you_might_like_artists"`
|
||||
}
|
||||
|
||||
// HistoryEvent is one play in a user's listening history. Used by
|
||||
|
||||
@@ -98,6 +98,7 @@ type RecommendationConfig struct {
|
||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
||||
ContextWeight float64 `yaml:"context_weight"`
|
||||
SimilarityWeight float64 `yaml:"similarity_weight"`
|
||||
TasteWeight float64 `yaml:"taste_weight"`
|
||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||
RadioSize int `yaml:"radio_size"`
|
||||
RadioSizeMax int `yaml:"radio_size_max"`
|
||||
@@ -119,13 +120,16 @@ func Default() Config {
|
||||
SkipMaxDurationPlayedMs: 30000,
|
||||
},
|
||||
Recommendation: RecommendationConfig{
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 1.0,
|
||||
JitterMagnitude: 0.1,
|
||||
ContextWeight: 2.0,
|
||||
SimilarityWeight: 2.0,
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 1.0,
|
||||
JitterMagnitude: 0.1,
|
||||
ContextWeight: 2.0,
|
||||
SimilarityWeight: 2.0,
|
||||
// Radio is seed-directed (the user picked a direction), so taste
|
||||
// is a lighter nudge here than in the daily mixes (1.5).
|
||||
TasteWeight: 1.0,
|
||||
RecentlyPlayedHours: 1,
|
||||
RadioSize: 50,
|
||||
RadioSizeMax: 200,
|
||||
|
||||
@@ -439,15 +439,6 @@ type ScanRun struct {
|
||||
ArtistArtEnrich []byte
|
||||
}
|
||||
|
||||
type ScanSchedule struct {
|
||||
ID bool
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type ScrobbleQueue struct {
|
||||
ID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
@@ -506,6 +497,20 @@ type SystemPlaylistRun struct {
|
||||
LastError *string
|
||||
}
|
||||
|
||||
type TasteProfileArtist struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Weight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileTag struct {
|
||||
UserID pgtype.UUID
|
||||
Tag string
|
||||
Weight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
ID pgtype.UUID
|
||||
Title string
|
||||
@@ -558,3 +563,17 @@ type UserInvite struct {
|
||||
RedeemedAt pgtype.Timestamptz
|
||||
RedeemedBy pgtype.UUID
|
||||
}
|
||||
|
||||
type YouMightLikeAlbum struct {
|
||||
UserID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
Rank int32
|
||||
BuiltAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type YouMightLikeArtist struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Rank int32
|
||||
BuiltAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
@@ -97,6 +97,83 @@ func (q *Queries) ListLastPlayedArtistsForUser(ctx context.Context, arg ListLast
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMostPlayedTracksForArtist = `-- name: ListMostPlayedTracksForArtist :many
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
FROM play_events
|
||||
WHERE user_id = $2 AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE t.artist_id = $1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $2 AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type ListMostPlayedTracksForArtistParams struct {
|
||||
ArtistID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
ResultLimit int32
|
||||
}
|
||||
|
||||
type ListMostPlayedTracksForArtistRow struct {
|
||||
Track Track
|
||||
AlbumTitle string
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Top tracks for one artist by this user's completed-play count (skips
|
||||
// excluded, quarantine filtered). Same projection as
|
||||
// ListMostPlayedTracksForUser plus an artist_id filter, so the handler
|
||||
// reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName).
|
||||
func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMostPlayedTracksForArtistParams) ([]ListMostPlayedTracksForArtistRow, error) {
|
||||
rows, err := q.db.Query(ctx, listMostPlayedTracksForArtist, arg.ArtistID, arg.UserID, arg.ResultLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListMostPlayedTracksForArtistRow
|
||||
for rows.Next() {
|
||||
var i ListMostPlayedTracksForArtistRow
|
||||
if err := rows.Scan(
|
||||
&i.Track.ID,
|
||||
&i.Track.Title,
|
||||
&i.Track.AlbumID,
|
||||
&i.Track.ArtistID,
|
||||
&i.Track.TrackNumber,
|
||||
&i.Track.DiscNumber,
|
||||
&i.Track.DurationMs,
|
||||
&i.Track.FilePath,
|
||||
&i.Track.FileSize,
|
||||
&i.Track.FileFormat,
|
||||
&i.Track.Bitrate,
|
||||
&i.Track.Mbid,
|
||||
&i.Track.Genre,
|
||||
&i.Track.AddedAt,
|
||||
&i.Track.UpdatedAt,
|
||||
&i.AlbumTitle,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listMostPlayedTracksForUser = `-- name: ListMostPlayedTracksForUser :many
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
@@ -660,6 +737,17 @@ likes_overlap AS (
|
||||
ORDER BY random()
|
||||
LIMIT $8
|
||||
),
|
||||
taste_overlap AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM taste_profile_artists tpa
|
||||
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||
WHERE tpa.user_id = $1
|
||||
AND tpa.weight > 0
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
AND t.id <> $2
|
||||
ORDER BY tpa.weight DESC, t.id
|
||||
LIMIT $10
|
||||
),
|
||||
random_fill AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -670,6 +758,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM similar_artists
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -686,6 +775,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
@@ -713,6 +803,7 @@ type LoadRadioCandidatesV2Params struct {
|
||||
Limit_3 int32
|
||||
Limit_4 int32
|
||||
Limit_5 int32
|
||||
Limit_6 int32
|
||||
}
|
||||
|
||||
type LoadRadioCandidatesV2Row struct {
|
||||
@@ -724,11 +815,15 @@ type LoadRadioCandidatesV2Row struct {
|
||||
SimilarityScore interface{}
|
||||
}
|
||||
|
||||
// M4c: similarity-driven candidate pool. 5-way UNION:
|
||||
// M4c: similarity-driven candidate pool. 6-way UNION:
|
||||
//
|
||||
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||||
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
|
||||
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||||
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||||
// enter the pool even when the similarity/random arms miss them; scored
|
||||
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||
//
|
||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||
@@ -742,6 +837,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
arg.Limit_3,
|
||||
arg.Limit_4,
|
||||
arg.Limit_5,
|
||||
arg.Limit_6,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: recommendation_metrics.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
|
||||
|
||||
SELECT
|
||||
pe.source,
|
||||
count(*)::bigint AS plays,
|
||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||
COALESCE(
|
||||
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
||||
0)::float8 AS avg_completion
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.source IS NOT NULL
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
GROUP BY pe.source
|
||||
ORDER BY plays DESC
|
||||
`
|
||||
|
||||
type RecommendationSourceMetricsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Column2 float64
|
||||
}
|
||||
|
||||
type RecommendationSourceMetricsForUserRow struct {
|
||||
Source *string
|
||||
Plays int64
|
||||
Skips int64
|
||||
AvgCompletion float64
|
||||
}
|
||||
|
||||
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||
// operator can see whether each recommendation surface is landing and tune the
|
||||
// taste weights. Source is stamped on play_events when a play is launched from
|
||||
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
||||
// NULL for library / radio / user-playlist plays, which are excluded here.
|
||||
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||
// mean completion ratio over plays that recorded one (0 when none did).
|
||||
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []RecommendationSourceMetricsForUserRow
|
||||
for rows.Next() {
|
||||
var i RecommendationSourceMetricsForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.Source,
|
||||
&i.Plays,
|
||||
&i.Skips,
|
||||
&i.AvgCompletion,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: scan_schedule.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getScanSchedule = `-- name: GetScanSchedule :one
|
||||
SELECT mode, interval_hours, time_of_day, weekly_day, updated_at
|
||||
FROM scan_schedule WHERE id = true
|
||||
`
|
||||
|
||||
type GetScanScheduleRow struct {
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
// M7 recurring-scans: returns the singleton schedule row. Used by the
|
||||
// scheduler goroutine on boot and config-refresh, and by the admin GET
|
||||
// handler.
|
||||
func (q *Queries) GetScanSchedule(ctx context.Context) (GetScanScheduleRow, error) {
|
||||
row := q.db.QueryRow(ctx, getScanSchedule)
|
||||
var i GetScanScheduleRow
|
||||
err := row.Scan(
|
||||
&i.Mode,
|
||||
&i.IntervalHours,
|
||||
&i.TimeOfDay,
|
||||
&i.WeeklyDay,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateScanSchedule = `-- name: UpdateScanSchedule :exec
|
||||
UPDATE scan_schedule
|
||||
SET mode = $1,
|
||||
interval_hours = $2,
|
||||
time_of_day = $3,
|
||||
weekly_day = $4,
|
||||
updated_at = now()
|
||||
WHERE id = true
|
||||
`
|
||||
|
||||
type UpdateScanScheduleParams struct {
|
||||
Mode string
|
||||
IntervalHours *int32
|
||||
TimeOfDay *string
|
||||
WeeklyDay *int32
|
||||
}
|
||||
|
||||
// Admin PATCH replaces the whole row; CHECK constraints enforce validity.
|
||||
// The application normalizes mode='off' to NULL the per-mode fields
|
||||
// before calling this.
|
||||
func (q *Queries) UpdateScanSchedule(ctx context.Context, arg UpdateScanScheduleParams) error {
|
||||
_, err := q.db.Exec(ctx, updateScanSchedule,
|
||||
arg.Mode,
|
||||
arg.IntervalHours,
|
||||
arg.TimeOfDay,
|
||||
arg.WeeklyDay,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -154,6 +154,77 @@ func (q *Queries) ListPlayedTracksNeedingSimilarity(ctx context.Context, limit i
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSimilarArtistsForArtist = `-- name: ListSimilarArtistsForArtist :many
|
||||
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM (
|
||||
SELECT artist_b_id, max(score) AS sim_score
|
||||
FROM artist_similarity
|
||||
WHERE artist_a_id = $1
|
||||
GROUP BY artist_b_id
|
||||
) s
|
||||
JOIN artists ON artists.id = s.artist_b_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
ORDER BY s.sim_score DESC, artists.sort_name
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListSimilarArtistsForArtistParams struct {
|
||||
SeedArtistID pgtype.UUID
|
||||
ResultLimit int32
|
||||
}
|
||||
|
||||
type ListSimilarArtistsForArtistRow struct {
|
||||
Artist Artist
|
||||
CoverAlbumID pgtype.UUID
|
||||
AlbumCount int64
|
||||
}
|
||||
|
||||
// In-library artists similar to a seed artist, ranked by best similarity
|
||||
// score across sources (deduped per candidate). cover_album_id + album_count
|
||||
// mirror ListArtistsAlphaWithCovers so the strip renders identical cards.
|
||||
func (q *Queries) ListSimilarArtistsForArtist(ctx context.Context, arg ListSimilarArtistsForArtistParams) ([]ListSimilarArtistsForArtistRow, error) {
|
||||
rows, err := q.db.Query(ctx, listSimilarArtistsForArtist, arg.SeedArtistID, arg.ResultLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListSimilarArtistsForArtistRow
|
||||
for rows.Next() {
|
||||
var i ListSimilarArtistsForArtistRow
|
||||
if err := rows.Scan(
|
||||
&i.Artist.ID,
|
||||
&i.Artist.Name,
|
||||
&i.Artist.SortName,
|
||||
&i.Artist.Mbid,
|
||||
&i.Artist.CreatedAt,
|
||||
&i.Artist.UpdatedAt,
|
||||
&i.Artist.ArtistThumbPath,
|
||||
&i.Artist.ArtistFanartPath,
|
||||
&i.Artist.ArtistArtSource,
|
||||
&i.Artist.ArtistArtSourcesVersion,
|
||||
&i.CoverAlbumID,
|
||||
&i.AlbumCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const upsertArtistSimilarity = `-- name: UpsertArtistSimilarity :exec
|
||||
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
|
||||
VALUES ($1, $2, $3, 'listenbrainz', now())
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: taste_profile.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const deleteTasteProfileArtistsForUser = `-- name: DeleteTasteProfileArtistsForUser :exec
|
||||
DELETE FROM taste_profile_artists WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTasteProfileArtistsForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
|
||||
DELETE FROM taste_profile_tags WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteTasteProfileTagsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTasteProfileTagsForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileArtist = `-- name: InsertTasteProfileArtist :exec
|
||||
INSERT INTO taste_profile_artists (user_id, artist_id, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertTasteProfileArtistParams struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteProfileArtistParams) error {
|
||||
_, err := q.db.Exec(ctx, insertTasteProfileArtist, arg.UserID, arg.ArtistID, arg.Weight)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
|
||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertTasteProfileTagParams struct {
|
||||
UserID pgtype.UUID
|
||||
Tag string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertTasteProfileTag(ctx context.Context, arg InsertTasteProfileTagParams) error {
|
||||
_, err := q.db.Exec(ctx, insertTasteProfileTag, arg.UserID, arg.Tag, arg.Weight)
|
||||
return err
|
||||
}
|
||||
|
||||
const listLikedArtistIDsForUser = `-- name: ListLikedArtistIDsForUser :many
|
||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
||||
`
|
||||
|
||||
// Artists the user has explicitly liked; feeds the artist-like bonus.
|
||||
func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedArtistIDsForUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []pgtype.UUID
|
||||
for rows.Next() {
|
||||
var artist_id pgtype.UUID
|
||||
if err := rows.Scan(&artist_id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, artist_id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
||||
SELECT t.artist_id, t.genre
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
WHERE gl.user_id = $1
|
||||
`
|
||||
|
||||
type ListLikedTrackTasteInputsForUserRow struct {
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
}
|
||||
|
||||
// (artist_id, genre) for each track the user has explicitly liked. Feeds the
|
||||
// track-like bonus into the liked track's artist and tags.
|
||||
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListLikedTrackTasteInputsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListLikedTrackTasteInputsForUserRow
|
||||
if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
|
||||
|
||||
SELECT
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
LEAST(GREATEST(
|
||||
COALESCE(pe.completion_ratio,
|
||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
||||
0), 0), 1)::float8 AS completion,
|
||||
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
)
|
||||
`
|
||||
|
||||
type ListPlayEngagementInputsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Column2 float64
|
||||
}
|
||||
|
||||
type ListPlayEngagementInputsForUserRow struct {
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
Completion float64
|
||||
AgeDays float64
|
||||
}
|
||||
|
||||
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
|
||||
// weights in Go (graded engagement + decay + like bonus) and atomic-replaces
|
||||
// them here. Read queries serve "top taste" lookups (used in phase 2).
|
||||
// One row per play in the decay-relevant window. completion is the play's
|
||||
// completion ratio (precomputed column when present, else duration_played /
|
||||
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
// is split into tags in Go. Quarantined tracks are excluded.
|
||||
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListPlayEngagementInputsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListPlayEngagementInputsForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.ArtistID,
|
||||
&i.Genre,
|
||||
&i.Completion,
|
||||
&i.AgeDays,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileArtistsForUser = `-- name: ListTasteProfileArtistsForUser :many
|
||||
SELECT artist_id, weight
|
||||
FROM taste_profile_artists
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListTasteProfileArtistsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListTasteProfileArtistsForUserRow struct {
|
||||
ArtistID pgtype.UUID
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// Top-weighted taste artists (phase-2 consumption + tests).
|
||||
func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTasteProfileArtistsForUserParams) ([]ListTasteProfileArtistsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTasteProfileArtistsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTasteProfileArtistsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListTasteProfileArtistsForUserRow
|
||||
if err := rows.Scan(&i.ArtistID, &i.Weight); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
|
||||
SELECT tag, weight
|
||||
FROM taste_profile_tags
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListTasteProfileTagsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListTasteProfileTagsForUserRow struct {
|
||||
Tag string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) ListTasteProfileTagsForUser(ctx context.Context, arg ListTasteProfileTagsForUserParams) ([]ListTasteProfileTagsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTasteProfileTagsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTasteProfileTagsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListTasteProfileTagsForUserRow
|
||||
if err := rows.Scan(&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
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: you_might_like.sql
|
||||
|
||||
package dbq
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const countListeningSignalForUser = `-- name: CountListeningSignalForUser :one
|
||||
|
||||
SELECT
|
||||
count(DISTINCT pe.track_id)::bigint AS distinct_tracks,
|
||||
count(DISTINCT t.artist_id)::bigint AS distinct_artists
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||
`
|
||||
|
||||
type CountListeningSignalForUserRow struct {
|
||||
DistinctTracks int64
|
||||
DistinctArtists int64
|
||||
}
|
||||
|
||||
// "You might like" Home rows (#790). The daily build computes ranked
|
||||
// album/artist IDs in Go (similarity roll-up + cold-start gate) and
|
||||
// atomic-replaces them here; /api/home reads them back hydrated.
|
||||
// Cold-start gate. Returns the breadth of the user's real listening:
|
||||
// distinct unskipped tracks played and distinct artists played. The
|
||||
// build skips You-might-like entirely below a threshold (a thin history
|
||||
// yields near-random roll-ups). was_skipped=false so a user spamming
|
||||
// next can't inflate the signal.
|
||||
func (q *Queries) CountListeningSignalForUser(ctx context.Context, userID pgtype.UUID) (CountListeningSignalForUserRow, error) {
|
||||
row := q.db.QueryRow(ctx, countListeningSignalForUser, userID)
|
||||
var i CountListeningSignalForUserRow
|
||||
err := row.Scan(&i.DistinctTracks, &i.DistinctArtists)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const deleteYouMightLikeAlbumsForUser = `-- name: DeleteYouMightLikeAlbumsForUser :exec
|
||||
DELETE FROM you_might_like_albums WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteYouMightLikeAlbumsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteYouMightLikeAlbumsForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteYouMightLikeArtistsForUser = `-- name: DeleteYouMightLikeArtistsForUser :exec
|
||||
DELETE FROM you_might_like_artists WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteYouMightLikeArtistsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteYouMightLikeArtistsForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertYouMightLikeAlbum = `-- name: InsertYouMightLikeAlbum :exec
|
||||
INSERT INTO you_might_like_albums (user_id, album_id, rank)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertYouMightLikeAlbumParams struct {
|
||||
UserID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
Rank int32
|
||||
}
|
||||
|
||||
func (q *Queries) InsertYouMightLikeAlbum(ctx context.Context, arg InsertYouMightLikeAlbumParams) error {
|
||||
_, err := q.db.Exec(ctx, insertYouMightLikeAlbum, arg.UserID, arg.AlbumID, arg.Rank)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertYouMightLikeArtist = `-- name: InsertYouMightLikeArtist :exec
|
||||
INSERT INTO you_might_like_artists (user_id, artist_id, rank)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertYouMightLikeArtistParams struct {
|
||||
UserID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Rank int32
|
||||
}
|
||||
|
||||
func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMightLikeArtistParams) error {
|
||||
_, err := q.db.Exec(ctx, insertYouMightLikeArtist, arg.UserID, arg.ArtistID, arg.Rank)
|
||||
return err
|
||||
}
|
||||
|
||||
const listYouMightLikeAlbumFallbackForUser = `-- name: ListYouMightLikeAlbumFallbackForUser :many
|
||||
WITH liked_album_ids AS (
|
||||
SELECT likb.album_id AS album_id
|
||||
FROM general_likes_albums likb
|
||||
WHERE likb.user_id = $1
|
||||
UNION
|
||||
SELECT trk.album_id AS album_id
|
||||
FROM general_likes likt
|
||||
JOIN tracks trk ON trk.id = likt.track_id
|
||||
WHERE likt.user_id = $1
|
||||
)
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||
FROM liked_album_ids lai
|
||||
JOIN albums ON albums.id = lai.album_id
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
ORDER BY md5(albums.id::text || current_date::text)
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListYouMightLikeAlbumFallbackForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListYouMightLikeAlbumFallbackForUserRow struct {
|
||||
Album Album
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS
|
||||
// the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser.
|
||||
func (q *Queries) ListYouMightLikeAlbumFallbackForUser(ctx context.Context, arg ListYouMightLikeAlbumFallbackForUserParams) ([]ListYouMightLikeAlbumFallbackForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listYouMightLikeAlbumFallbackForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListYouMightLikeAlbumFallbackForUserRow
|
||||
for rows.Next() {
|
||||
var i ListYouMightLikeAlbumFallbackForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.Album.ID,
|
||||
&i.Album.Title,
|
||||
&i.Album.SortTitle,
|
||||
&i.Album.ArtistID,
|
||||
&i.Album.ReleaseDate,
|
||||
&i.Album.Mbid,
|
||||
&i.Album.CoverArtPath,
|
||||
&i.Album.CreatedAt,
|
||||
&i.Album.UpdatedAt,
|
||||
&i.Album.CoverArtSource,
|
||||
&i.Album.CoverArtSourcesVersion,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many
|
||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||
FROM you_might_like_albums yml
|
||||
JOIN albums ON albums.id = yml.album_id
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE yml.user_id = $1
|
||||
ORDER BY yml.rank
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListYouMightLikeAlbumsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListYouMightLikeAlbumsForUserRow struct {
|
||||
Album Album
|
||||
ArtistName string
|
||||
}
|
||||
|
||||
// Read path. Same projection as ListRediscoverAlbumsForUser so the API
|
||||
// layer reuses albumRefFrom(row.Album, row.ArtistName, …). Ordered by
|
||||
// the rank persisted at build time. Final cross-section dedup (vs Most
|
||||
// Played / Rediscover) and the output cap land in the Go layer
|
||||
// (internal/recommendation/home.go).
|
||||
func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYouMightLikeAlbumsForUserParams) ([]ListYouMightLikeAlbumsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listYouMightLikeAlbumsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListYouMightLikeAlbumsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListYouMightLikeAlbumsForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.Album.ID,
|
||||
&i.Album.Title,
|
||||
&i.Album.SortTitle,
|
||||
&i.Album.ArtistID,
|
||||
&i.Album.ReleaseDate,
|
||||
&i.Album.Mbid,
|
||||
&i.Album.CoverArtPath,
|
||||
&i.Album.CreatedAt,
|
||||
&i.Album.UpdatedAt,
|
||||
&i.Album.CoverArtSource,
|
||||
&i.Album.CoverArtSourcesVersion,
|
||||
&i.ArtistName,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listYouMightLikeArtistFallbackForUser = `-- name: ListYouMightLikeArtistFallbackForUser :many
|
||||
WITH liked_artist_ids AS (
|
||||
SELECT lika.artist_id AS artist_id
|
||||
FROM general_likes_artists lika
|
||||
WHERE lika.user_id = $1
|
||||
UNION
|
||||
SELECT alb.artist_id AS artist_id
|
||||
FROM general_likes_albums likb
|
||||
JOIN albums alb ON alb.id = likb.album_id
|
||||
WHERE likb.user_id = $1
|
||||
UNION
|
||||
SELECT trk.artist_id AS artist_id
|
||||
FROM general_likes likt
|
||||
JOIN tracks trk ON trk.id = likt.track_id
|
||||
WHERE likt.user_id = $1
|
||||
)
|
||||
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM liked_artist_ids lai
|
||||
JOIN artists ON artists.id = lai.artist_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
ORDER BY md5(artists.id::text || current_date::text)
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListYouMightLikeArtistFallbackForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListYouMightLikeArtistFallbackForUserRow struct {
|
||||
Artist Artist
|
||||
CoverAlbumID pgtype.UUID
|
||||
AlbumCount int64
|
||||
}
|
||||
|
||||
// Broad "artists you've shown affinity for" pool to fill a thin
|
||||
// You-might-like artists row: explicitly liked artists PLUS the artists of
|
||||
// liked albums and liked tracks. Most users like albums/tracks far more than
|
||||
// they like artists explicitly, so the union is much deeper than
|
||||
// general_likes_artists alone. Daily-stable order; same projection as
|
||||
// ListYouMightLikeArtistsForUser so the Go layer reuses it directly.
|
||||
func (q *Queries) ListYouMightLikeArtistFallbackForUser(ctx context.Context, arg ListYouMightLikeArtistFallbackForUserParams) ([]ListYouMightLikeArtistFallbackForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listYouMightLikeArtistFallbackForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListYouMightLikeArtistFallbackForUserRow
|
||||
for rows.Next() {
|
||||
var i ListYouMightLikeArtistFallbackForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.Artist.ID,
|
||||
&i.Artist.Name,
|
||||
&i.Artist.SortName,
|
||||
&i.Artist.Mbid,
|
||||
&i.Artist.CreatedAt,
|
||||
&i.Artist.UpdatedAt,
|
||||
&i.Artist.ArtistThumbPath,
|
||||
&i.Artist.ArtistFanartPath,
|
||||
&i.Artist.ArtistArtSource,
|
||||
&i.Artist.ArtistArtSourcesVersion,
|
||||
&i.CoverAlbumID,
|
||||
&i.AlbumCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many
|
||||
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM you_might_like_artists yml
|
||||
JOIN artists ON artists.id = yml.artist_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
WHERE yml.user_id = $1
|
||||
ORDER BY yml.rank
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListYouMightLikeArtistsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListYouMightLikeArtistsForUserRow struct {
|
||||
Artist Artist
|
||||
CoverAlbumID pgtype.UUID
|
||||
AlbumCount int64
|
||||
}
|
||||
|
||||
// Read path. Same projection as ListRediscoverArtistsForUser (embeds the
|
||||
// artist + a representative cover_album_id + album_count) so the API
|
||||
// layer reuses artistRefFromCovered. Ordered by persisted rank.
|
||||
func (q *Queries) ListYouMightLikeArtistsForUser(ctx context.Context, arg ListYouMightLikeArtistsForUserParams) ([]ListYouMightLikeArtistsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listYouMightLikeArtistsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListYouMightLikeArtistsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListYouMightLikeArtistsForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.Artist.ID,
|
||||
&i.Artist.Name,
|
||||
&i.Artist.SortName,
|
||||
&i.Artist.Mbid,
|
||||
&i.Artist.CreatedAt,
|
||||
&i.Artist.UpdatedAt,
|
||||
&i.Artist.ArtistThumbPath,
|
||||
&i.Artist.ArtistFanartPath,
|
||||
&i.Artist.ArtistArtSource,
|
||||
&i.Artist.ArtistArtSourcesVersion,
|
||||
&i.CoverAlbumID,
|
||||
&i.AlbumCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Recreate the scan_schedule singleton (mirror of 0019) so the drop is
|
||||
-- reversible. The application no longer reads or writes this table.
|
||||
CREATE TABLE scan_schedule (
|
||||
id boolean PRIMARY KEY DEFAULT true,
|
||||
mode text NOT NULL DEFAULT 'off',
|
||||
interval_hours int,
|
||||
time_of_day text,
|
||||
weekly_day int,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT scan_schedule_singleton CHECK (id = true),
|
||||
CONSTRAINT scan_schedule_mode_check
|
||||
CHECK (mode IN ('off', 'interval', 'daily', 'weekly')),
|
||||
CONSTRAINT scan_schedule_interval_check
|
||||
CHECK (mode != 'interval' OR (interval_hours IS NOT NULL AND interval_hours > 0)),
|
||||
CONSTRAINT scan_schedule_daily_check
|
||||
CHECK (mode != 'daily' OR time_of_day IS NOT NULL),
|
||||
CONSTRAINT scan_schedule_weekly_check
|
||||
CHECK (mode != 'weekly' OR (time_of_day IS NOT NULL AND weekly_day IS NOT NULL AND weekly_day BETWEEN 1 AND 7))
|
||||
);
|
||||
|
||||
INSERT INTO scan_schedule (id) VALUES (true);
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Retire the configurable scan scheduler. New-file pickup is now handled by
|
||||
-- the fsnotify watcher + a hardcoded safety-net delta walk (cmd/minstrel),
|
||||
-- so the operator-configurable schedule (and its admin UI) is gone.
|
||||
DROP TABLE IF EXISTS scan_schedule;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Reverse 0034_you_might_like.up.sql.
|
||||
DROP TABLE IF EXISTS you_might_like_artists;
|
||||
DROP TABLE IF EXISTS you_might_like_albums;
|
||||
@@ -0,0 +1,36 @@
|
||||
-- 0034_you_might_like.up.sql — "You might like" Home rows (#790).
|
||||
--
|
||||
-- Per-user ranked lists of IN-LIBRARY albums/artists the listener
|
||||
-- doesn't actively spin but is predicted to enjoy, derived from the
|
||||
-- same similarity + like-weighted candidate engine that powers For-You
|
||||
-- (rolled up from track scores to album/artist). Built in the daily
|
||||
-- 3am BuildSystemPlaylists pass and atomic-replaced, exactly like the
|
||||
-- system playlists; read back by /api/home.
|
||||
--
|
||||
-- Two thin tables (id + rank) rather than denormalized snapshots: the
|
||||
-- Home read path hydrates album/artist refs through the existing
|
||||
-- albums/artists joins, so a later metadata edit is reflected without
|
||||
-- a rebuild. CASCADE on user/album/artist deletes keeps them clean.
|
||||
|
||||
CREATE TABLE you_might_like_albums (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
album_id uuid NOT NULL REFERENCES albums(id) ON DELETE CASCADE,
|
||||
rank integer NOT NULL,
|
||||
built_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, album_id)
|
||||
);
|
||||
|
||||
-- Read path orders by rank within a user; the index serves it directly.
|
||||
CREATE INDEX you_might_like_albums_user_rank_idx
|
||||
ON you_might_like_albums (user_id, rank);
|
||||
|
||||
CREATE TABLE you_might_like_artists (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
||||
rank integer NOT NULL,
|
||||
built_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, artist_id)
|
||||
);
|
||||
|
||||
CREATE INDEX you_might_like_artists_user_rank_idx
|
||||
ON you_might_like_artists (user_id, rank);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Reverse 0035_taste_profile.up.sql.
|
||||
DROP TABLE IF EXISTS taste_profile_tags;
|
||||
DROP TABLE IF EXISTS taste_profile_artists;
|
||||
@@ -0,0 +1,33 @@
|
||||
-- 0035_taste_profile.up.sql — per-user taste profile (#796 phase 1).
|
||||
--
|
||||
-- A persistent, decaying model of each user's taste, rebuilt daily from
|
||||
-- graded play engagement (completion + skip position) plus explicit
|
||||
-- likes. Two facet tables: weighted artists and weighted genre tags.
|
||||
-- Weight is a signed float — positive = drawn to, negative = (passively)
|
||||
-- avoided; magnitude reflects decayed engagement. Phase 1 only BUILDS
|
||||
-- these; consumption (candidate sourcing + scoring) lands in phase 2.
|
||||
--
|
||||
-- CASCADE on user/artist deletes keeps them clean. Indexed by
|
||||
-- (user_id, weight DESC) to serve "top taste artists/tags" reads cheaply.
|
||||
|
||||
CREATE TABLE taste_profile_artists (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
artist_id uuid NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
|
||||
weight double precision NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, artist_id)
|
||||
);
|
||||
|
||||
CREATE INDEX taste_profile_artists_user_weight_idx
|
||||
ON taste_profile_artists (user_id, weight DESC);
|
||||
|
||||
CREATE TABLE taste_profile_tags (
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
tag text NOT NULL,
|
||||
weight double precision NOT NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, tag)
|
||||
);
|
||||
|
||||
CREATE INDEX taste_profile_tags_user_weight_idx
|
||||
ON taste_profile_tags (user_id, weight DESC);
|
||||
@@ -33,10 +33,14 @@ WHERE t.id <> $2
|
||||
);
|
||||
|
||||
-- name: LoadRadioCandidatesV2 :many
|
||||
-- M4c: similarity-driven candidate pool. 5-way UNION:
|
||||
-- M4c: similarity-driven candidate pool. 6-way UNION:
|
||||
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||||
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
|
||||
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||||
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||
-- positively-weighted taste-profile artists, so taste-relevant tracks
|
||||
-- enter the pool even when the similarity/random arms miss them; scored
|
||||
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
|
||||
WITH
|
||||
@@ -109,6 +113,17 @@ likes_overlap AS (
|
||||
ORDER BY random()
|
||||
LIMIT $8
|
||||
),
|
||||
taste_overlap AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM taste_profile_artists tpa
|
||||
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||
WHERE tpa.user_id = $1
|
||||
AND tpa.weight > 0
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
AND t.id <> $2
|
||||
ORDER BY tpa.weight DESC, t.id
|
||||
LIMIT $10
|
||||
),
|
||||
random_fill AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -119,6 +134,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM similar_artists
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -135,6 +151,7 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
@@ -231,6 +248,32 @@ WHERE NOT EXISTS (
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListMostPlayedTracksForArtist :many
|
||||
-- Top tracks for one artist by this user's completed-play count (skips
|
||||
-- excluded, quarantine filtered). Same projection as
|
||||
-- ListMostPlayedTracksForUser plus an artist_id filter, so the handler
|
||||
-- reuses trackRefFrom(row.Track, row.AlbumTitle, row.ArtistName).
|
||||
WITH plays AS (
|
||||
SELECT track_id, count(*) AS cnt
|
||||
FROM play_events
|
||||
WHERE user_id = sqlc.arg(user_id) AND was_skipped = false
|
||||
GROUP BY track_id
|
||||
)
|
||||
SELECT sqlc.embed(t),
|
||||
albums.title AS album_title,
|
||||
artists.name AS artist_name
|
||||
FROM plays p
|
||||
JOIN tracks t ON t.id = p.track_id
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE t.artist_id = sqlc.arg(artist_id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = sqlc.arg(user_id) AND q.track_id = t.id
|
||||
)
|
||||
ORDER BY p.cnt DESC, t.id
|
||||
LIMIT sqlc.arg(result_limit);
|
||||
|
||||
-- name: ListLastPlayedArtistsForUser :many
|
||||
-- M6a: artists ranked by max(play_events.started_at) for the user, with
|
||||
-- a derived cover_album_id via a representative-album lateral join (most
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||
-- operator can see whether each recommendation surface is landing and tune the
|
||||
-- taste weights. Source is stamped on play_events when a play is launched from
|
||||
-- a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
||||
-- NULL for library / radio / user-playlist plays, which are excluded here.
|
||||
|
||||
-- name: RecommendationSourceMetricsForUser :many
|
||||
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||
-- mean completion ratio over plays that recorded one (0 when none did).
|
||||
SELECT
|
||||
pe.source,
|
||||
count(*)::bigint AS plays,
|
||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||
COALESCE(
|
||||
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
||||
0)::float8 AS avg_completion
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.source IS NOT NULL
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
GROUP BY pe.source
|
||||
ORDER BY plays DESC;
|
||||
@@ -1,18 +0,0 @@
|
||||
-- name: GetScanSchedule :one
|
||||
-- M7 recurring-scans: returns the singleton schedule row. Used by the
|
||||
-- scheduler goroutine on boot and config-refresh, and by the admin GET
|
||||
-- handler.
|
||||
SELECT mode, interval_hours, time_of_day, weekly_day, updated_at
|
||||
FROM scan_schedule WHERE id = true;
|
||||
|
||||
-- name: UpdateScanSchedule :exec
|
||||
-- Admin PATCH replaces the whole row; CHECK constraints enforce validity.
|
||||
-- The application normalizes mode='off' to NULL the per-mode fields
|
||||
-- before calling this.
|
||||
UPDATE scan_schedule
|
||||
SET mode = $1,
|
||||
interval_hours = $2,
|
||||
time_of_day = $3,
|
||||
weekly_day = $4,
|
||||
updated_at = now()
|
||||
WHERE id = true;
|
||||
@@ -60,3 +60,29 @@ ON CONFLICT (seed_artist_id, candidate_mbid, source) DO UPDATE SET
|
||||
candidate_name = EXCLUDED.candidate_name,
|
||||
score = EXCLUDED.score,
|
||||
fetched_at = now();
|
||||
|
||||
-- name: ListSimilarArtistsForArtist :many
|
||||
-- In-library artists similar to a seed artist, ranked by best similarity
|
||||
-- score across sources (deduped per candidate). cover_album_id + album_count
|
||||
-- mirror ListArtistsAlphaWithCovers so the strip renders identical cards.
|
||||
SELECT sqlc.embed(artists),
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM (
|
||||
SELECT artist_b_id, max(score) AS sim_score
|
||||
FROM artist_similarity
|
||||
WHERE artist_a_id = sqlc.arg(seed_artist_id)
|
||||
GROUP BY artist_b_id
|
||||
) s
|
||||
JOIN artists ON artists.id = s.artist_b_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
ORDER BY s.sim_score DESC, artists.sort_name
|
||||
LIMIT sqlc.arg(result_limit);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Taste profile (#796 phase 1). The daily build computes signed artist/tag
|
||||
-- weights in Go (graded engagement + decay + like bonus) and atomic-replaces
|
||||
-- them here. Read queries serve "top taste" lookups (used in phase 2).
|
||||
|
||||
-- name: ListPlayEngagementInputsForUser :many
|
||||
-- One row per play in the decay-relevant window. completion is the play's
|
||||
-- completion ratio (precomputed column when present, else duration_played /
|
||||
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
-- is split into tags in Go. Quarantined tracks are excluded.
|
||||
SELECT
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
LEAST(GREATEST(
|
||||
COALESCE(pe.completion_ratio,
|
||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
||||
0), 0), 1)::float8 AS completion,
|
||||
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM lidarr_quarantine q
|
||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||
);
|
||||
|
||||
-- name: ListLikedTrackTasteInputsForUser :many
|
||||
-- (artist_id, genre) for each track the user has explicitly liked. Feeds the
|
||||
-- track-like bonus into the liked track's artist and tags.
|
||||
SELECT t.artist_id, t.genre
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
WHERE gl.user_id = $1;
|
||||
|
||||
-- name: ListLikedArtistIDsForUser :many
|
||||
-- Artists the user has explicitly liked; feeds the artist-like bonus.
|
||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1;
|
||||
|
||||
-- name: DeleteTasteProfileArtistsForUser :exec
|
||||
DELETE FROM taste_profile_artists WHERE user_id = $1;
|
||||
|
||||
-- name: InsertTasteProfileArtist :exec
|
||||
INSERT INTO taste_profile_artists (user_id, artist_id, weight)
|
||||
VALUES ($1, $2, $3);
|
||||
|
||||
-- name: DeleteTasteProfileTagsForUser :exec
|
||||
DELETE FROM taste_profile_tags WHERE user_id = $1;
|
||||
|
||||
-- name: InsertTasteProfileTag :exec
|
||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||
VALUES ($1, $2, $3);
|
||||
|
||||
-- name: ListTasteProfileArtistsForUser :many
|
||||
-- Top-weighted taste artists (phase-2 consumption + tests).
|
||||
SELECT artist_id, weight
|
||||
FROM taste_profile_artists
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListTasteProfileTagsForUser :many
|
||||
SELECT tag, weight
|
||||
FROM taste_profile_tags
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2;
|
||||
@@ -0,0 +1,125 @@
|
||||
-- "You might like" Home rows (#790). The daily build computes ranked
|
||||
-- album/artist IDs in Go (similarity roll-up + cold-start gate) and
|
||||
-- atomic-replaces them here; /api/home reads them back hydrated.
|
||||
|
||||
-- name: CountListeningSignalForUser :one
|
||||
-- Cold-start gate. Returns the breadth of the user's real listening:
|
||||
-- distinct unskipped tracks played and distinct artists played. The
|
||||
-- build skips You-might-like entirely below a threshold (a thin history
|
||||
-- yields near-random roll-ups). was_skipped=false so a user spamming
|
||||
-- next can't inflate the signal.
|
||||
SELECT
|
||||
count(DISTINCT pe.track_id)::bigint AS distinct_tracks,
|
||||
count(DISTINCT t.artist_id)::bigint AS distinct_artists
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
WHERE pe.user_id = $1 AND pe.was_skipped = false;
|
||||
|
||||
-- name: DeleteYouMightLikeAlbumsForUser :exec
|
||||
DELETE FROM you_might_like_albums WHERE user_id = $1;
|
||||
|
||||
-- name: InsertYouMightLikeAlbum :exec
|
||||
INSERT INTO you_might_like_albums (user_id, album_id, rank)
|
||||
VALUES ($1, $2, $3);
|
||||
|
||||
-- name: DeleteYouMightLikeArtistsForUser :exec
|
||||
DELETE FROM you_might_like_artists WHERE user_id = $1;
|
||||
|
||||
-- name: InsertYouMightLikeArtist :exec
|
||||
INSERT INTO you_might_like_artists (user_id, artist_id, rank)
|
||||
VALUES ($1, $2, $3);
|
||||
|
||||
-- name: ListYouMightLikeAlbumsForUser :many
|
||||
-- Read path. Same projection as ListRediscoverAlbumsForUser so the API
|
||||
-- layer reuses albumRefFrom(row.Album, row.ArtistName, …). Ordered by
|
||||
-- the rank persisted at build time. Final cross-section dedup (vs Most
|
||||
-- Played / Rediscover) and the output cap land in the Go layer
|
||||
-- (internal/recommendation/home.go).
|
||||
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||
FROM you_might_like_albums yml
|
||||
JOIN albums ON albums.id = yml.album_id
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
WHERE yml.user_id = $1
|
||||
ORDER BY yml.rank
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListYouMightLikeArtistsForUser :many
|
||||
-- Read path. Same projection as ListRediscoverArtistsForUser (embeds the
|
||||
-- artist + a representative cover_album_id + album_count) so the API
|
||||
-- layer reuses artistRefFromCovered. Ordered by persisted rank.
|
||||
SELECT sqlc.embed(artists),
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM you_might_like_artists yml
|
||||
JOIN artists ON artists.id = yml.artist_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
WHERE yml.user_id = $1
|
||||
ORDER BY yml.rank
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListYouMightLikeArtistFallbackForUser :many
|
||||
-- Broad "artists you've shown affinity for" pool to fill a thin
|
||||
-- You-might-like artists row: explicitly liked artists PLUS the artists of
|
||||
-- liked albums and liked tracks. Most users like albums/tracks far more than
|
||||
-- they like artists explicitly, so the union is much deeper than
|
||||
-- general_likes_artists alone. Daily-stable order; same projection as
|
||||
-- ListYouMightLikeArtistsForUser so the Go layer reuses it directly.
|
||||
WITH liked_artist_ids AS (
|
||||
SELECT lika.artist_id AS artist_id
|
||||
FROM general_likes_artists lika
|
||||
WHERE lika.user_id = $1
|
||||
UNION
|
||||
SELECT alb.artist_id AS artist_id
|
||||
FROM general_likes_albums likb
|
||||
JOIN albums alb ON alb.id = likb.album_id
|
||||
WHERE likb.user_id = $1
|
||||
UNION
|
||||
SELECT trk.artist_id AS artist_id
|
||||
FROM general_likes likt
|
||||
JOIN tracks trk ON trk.id = likt.track_id
|
||||
WHERE likt.user_id = $1
|
||||
)
|
||||
SELECT sqlc.embed(artists),
|
||||
cov.id AS cover_album_id,
|
||||
cnt.album_count::bigint AS album_count
|
||||
FROM liked_artist_ids lai
|
||||
JOIN artists ON artists.id = lai.artist_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT id FROM albums
|
||||
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
) cov ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*) AS album_count
|
||||
FROM albums WHERE artist_id = artists.id
|
||||
) cnt ON true
|
||||
ORDER BY md5(artists.id::text || current_date::text)
|
||||
LIMIT $2;
|
||||
|
||||
-- name: ListYouMightLikeAlbumFallbackForUser :many
|
||||
-- Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS
|
||||
-- the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser.
|
||||
WITH liked_album_ids AS (
|
||||
SELECT likb.album_id AS album_id
|
||||
FROM general_likes_albums likb
|
||||
WHERE likb.user_id = $1
|
||||
UNION
|
||||
SELECT trk.album_id AS album_id
|
||||
FROM general_likes likt
|
||||
JOIN tracks trk ON trk.id = likt.track_id
|
||||
WHERE likt.user_id = $1
|
||||
)
|
||||
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||
FROM liked_album_ids lai
|
||||
JOIN albums ON albums.id = lai.album_id
|
||||
JOIN artists ON artists.id = albums.artist_id
|
||||
ORDER BY md5(albums.id::text || current_date::text)
|
||||
LIMIT $2;
|
||||
+56
-11
@@ -87,7 +87,7 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
||||
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
||||
return nil
|
||||
}
|
||||
if err := s.scanFile(ctx, q, path, &stats); err != nil {
|
||||
if _, _, err := s.scanFile(ctx, q, path, &stats); err != nil {
|
||||
s.logger.Warn("library scan file error", "path", path, "err", err)
|
||||
stats.Errored++
|
||||
}
|
||||
@@ -114,19 +114,24 @@ func (s *Scanner) Scan(ctx context.Context, progressCb func(Stats)) (Stats, erro
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, stats *Stats) error {
|
||||
// scanFile upserts a single audio file. Returns the album ID the track
|
||||
// belongs to and whether the file was added/updated (false = skipped as
|
||||
// unchanged), so watcher-driven callers can enrich just the changed albums.
|
||||
func (s *Scanner) scanFile(
|
||||
ctx context.Context, q *dbq.Queries, path string, stats *Stats,
|
||||
) (pgtype.UUID, bool, error) {
|
||||
stats.Scanned++
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("stat: %w", err)
|
||||
}
|
||||
mtime := info.ModTime()
|
||||
|
||||
existing, err := q.GetTrackByPath(ctx, path)
|
||||
knownTrack := err == nil
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("lookup: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("lookup: %w", err)
|
||||
}
|
||||
// Incremental skip: only when the file hasn't changed AND we already have
|
||||
// a real duration. The second clause lets older scans that recorded
|
||||
@@ -135,18 +140,18 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
||||
// scans short-circuit as before.
|
||||
if knownTrack && !existing.UpdatedAt.Time.Before(mtime) && existing.DurationMs > 0 {
|
||||
stats.Skipped++
|
||||
return nil
|
||||
return pgtype.UUID{}, false, nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("open: %w", err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
meta, err := tag.ReadFrom(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tag read: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("tag read: %w", err)
|
||||
}
|
||||
albumMBID, artistMBID := extractMBIDs(meta)
|
||||
recordingMBID := extractRecordingMBID(meta)
|
||||
@@ -166,11 +171,11 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
||||
|
||||
artist, err := s.resolveArtist(ctx, q, artistName, artistMBID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("artist: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("artist: %w", err)
|
||||
}
|
||||
album, err := s.resolveAlbum(ctx, q, artist.ID, albumTitle, meta.Year(), albumMBID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("album: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("album: %w", err)
|
||||
}
|
||||
|
||||
trackNum, _ := meta.Track()
|
||||
@@ -213,7 +218,7 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
||||
|
||||
track, err := q.UpsertTrack(ctx, params)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert track: %w", err)
|
||||
return pgtype.UUID{}, false, fmt.Errorf("upsert track: %w", err)
|
||||
}
|
||||
if err := syncpkg.LogChange(ctx, s.pool, syncpkg.EntityTrack,
|
||||
syncpkg.FormatUUID(track.ID), syncpkg.OpUpsert); err != nil {
|
||||
@@ -227,7 +232,47 @@ func (s *Scanner) scanFile(ctx context.Context, q *dbq.Queries, path string, sta
|
||||
} else {
|
||||
stats.Added++
|
||||
}
|
||||
return nil
|
||||
return album.ID, true, nil
|
||||
}
|
||||
|
||||
// ScanFiles processes a specific set of audio file paths (watcher-driven),
|
||||
// applying the same upsert + delta-skip logic as a full Scan. Non-audio or
|
||||
// unreadable paths are skipped (logged), never fatal. Returns the distinct
|
||||
// album IDs whose tracks were added or updated, so the caller can enrich
|
||||
// just those albums inline rather than waiting for a batch pass.
|
||||
func (s *Scanner) ScanFiles(ctx context.Context, paths []string) ([]pgtype.UUID, error) {
|
||||
q := dbq.New(s.pool)
|
||||
seen := make(map[[16]byte]struct{})
|
||||
changed := make([]pgtype.UUID, 0)
|
||||
var stats Stats
|
||||
for _, path := range paths {
|
||||
if ctx.Err() != nil {
|
||||
return changed, ctx.Err()
|
||||
}
|
||||
if !audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
||||
continue
|
||||
}
|
||||
albumID, didChange, err := s.scanFile(ctx, q, path, &stats)
|
||||
if err != nil {
|
||||
s.logger.Warn("library watch: scan file error", "path", path, "err", err)
|
||||
continue
|
||||
}
|
||||
if didChange && albumID.Valid {
|
||||
if _, ok := seen[albumID.Bytes]; !ok {
|
||||
seen[albumID.Bytes] = struct{}{}
|
||||
changed = append(changed, albumID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if stats.Added > 0 || stats.Updated > 0 {
|
||||
s.logger.Info("library watch: scan batch",
|
||||
"added", stats.Added,
|
||||
"updated", stats.Updated,
|
||||
"skipped", stats.Skipped,
|
||||
"errored", stats.Errored,
|
||||
)
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *Scanner) resolveArtist(ctx context.Context, q *dbq.Queries, name, mbid string) (dbq.Artist, error) {
|
||||
|
||||
@@ -121,6 +121,45 @@ func TestScanner_Integration(t *testing.T) {
|
||||
if stats2.Added != 0 || stats2.Updated != 0 || stats2.Skipped != 4 {
|
||||
t.Errorf("incremental scan stats = %+v, want Skipped=4", stats2)
|
||||
}
|
||||
|
||||
// ScanFiles (watcher-driven targeted scan): a newly-added file is picked
|
||||
// up by path without walking the whole tree, and its album ID is returned
|
||||
// so the watcher can enrich just that album. A non-audio path in the batch
|
||||
// is silently ignored.
|
||||
newFile := filepath.Join(root, "artistX/albumA/03.mp3")
|
||||
writeTestMP3(t, newFile, map[string]string{
|
||||
"TIT2": "Track Three", "TPE1": "Artist X", "TALB": "Album A", "TRCK": "3", "TYER": "2020",
|
||||
})
|
||||
notAudio := filepath.Join(root, "artistX/albumA/cover.txt")
|
||||
if err := os.WriteFile(notAudio, []byte("nope"), 0o644); err != nil {
|
||||
t.Fatalf("write non-audio: %v", err)
|
||||
}
|
||||
changed, err := scanner.ScanFiles(ctx, []string{newFile, notAudio})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFiles: %v", err)
|
||||
}
|
||||
if len(changed) != 1 || !changed[0].Valid {
|
||||
t.Fatalf("ScanFiles changed albums = %+v, want exactly 1 valid", changed)
|
||||
}
|
||||
|
||||
// The targeted scan upserts only the named file; the rest of the library
|
||||
// is untouched (no full walk).
|
||||
if _, err := q.GetTrackByPath(ctx, newFile); err != nil {
|
||||
t.Fatalf("GetTrackByPath after ScanFiles: %v", err)
|
||||
}
|
||||
|
||||
// Re-running ScanFiles on the now-unchanged file (duration probed) skips it,
|
||||
// so no album is reported as changed.
|
||||
if _, err := pool.Exec(ctx, "UPDATE tracks SET duration_ms = 1000 WHERE duration_ms = 0"); err != nil {
|
||||
t.Fatalf("seed durations (2): %v", err)
|
||||
}
|
||||
changed2, err := scanner.ScanFiles(ctx, []string{newFile})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFiles (rescan): %v", err)
|
||||
}
|
||||
if len(changed2) != 0 {
|
||||
t.Errorf("ScanFiles on unchanged file changed = %+v, want none", changed2)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTestMP3 emits a file with only an ID3v2.3 tag payload plus a few
|
||||
|
||||
@@ -1,238 +0,0 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// ScheduleConfig represents the operator's recurring scan schedule.
|
||||
// Loaded from the scan_schedule singleton row on boot and on Refresh.
|
||||
type ScheduleConfig struct {
|
||||
Mode string // "off" | "interval" | "daily" | "weekly"
|
||||
IntervalHours int
|
||||
TimeOfDay string // "HH:MM" (24h)
|
||||
WeeklyDay int // 1..7 (Mon..Sun, ISO 8601)
|
||||
}
|
||||
|
||||
// NextFire returns the next time the scan should fire given a reference
|
||||
// "now". Returns zero time when mode == "off" or config is invalid.
|
||||
//
|
||||
// DST semantics: "daily 02:30" on a spring-forward day will fire at the
|
||||
// computed time using the local zone's rules (Go time.Date silently
|
||||
// normalizes a non-existent local time). Single-operator scale; documented
|
||||
// as known and acceptable.
|
||||
func (c ScheduleConfig) NextFire(now time.Time) time.Time {
|
||||
switch c.Mode {
|
||||
case "off":
|
||||
return time.Time{}
|
||||
case "interval":
|
||||
if c.IntervalHours <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return now.Add(time.Duration(c.IntervalHours) * time.Hour)
|
||||
case "daily":
|
||||
h, m, ok := parseHHMM(c.TimeOfDay)
|
||||
if !ok {
|
||||
return time.Time{}
|
||||
}
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location())
|
||||
if !next.After(now) {
|
||||
next = next.AddDate(0, 0, 1)
|
||||
}
|
||||
return next
|
||||
case "weekly":
|
||||
h, m, ok := parseHHMM(c.TimeOfDay)
|
||||
if !ok || c.WeeklyDay < 1 || c.WeeklyDay > 7 {
|
||||
return time.Time{}
|
||||
}
|
||||
// Map ISO weekday (1=Mon..7=Sun) to Go's time.Weekday (0=Sun..6=Sat).
|
||||
targetGoDow := c.WeeklyDay % 7
|
||||
nowGoDow := int(now.Weekday())
|
||||
daysAhead := (targetGoDow - nowGoDow + 7) % 7
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), h, m, 0, 0, now.Location())
|
||||
next = next.AddDate(0, 0, daysAhead)
|
||||
if !next.After(now) {
|
||||
next = next.AddDate(0, 0, 7)
|
||||
}
|
||||
return next
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func parseHHMM(s string) (h, m int, ok bool) {
|
||||
if len(s) != 5 || s[2] != ':' {
|
||||
return 0, 0, false
|
||||
}
|
||||
for i, c := range s {
|
||||
if i == 2 {
|
||||
continue
|
||||
}
|
||||
if c < '0' || c > '9' {
|
||||
return 0, 0, false
|
||||
}
|
||||
}
|
||||
h = int(s[0]-'0')*10 + int(s[1]-'0')
|
||||
m = int(s[3]-'0')*10 + int(s[4]-'0')
|
||||
if h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return h, m, true
|
||||
}
|
||||
|
||||
// Scheduler runs in a goroutine and triggers RunScan on the operator's
|
||||
// configured cadence via library.TryStartScan (which handles in-flight
|
||||
// conflicts and stale-row reaping).
|
||||
type Scheduler struct {
|
||||
pool *pgxpool.Pool
|
||||
logger *slog.Logger
|
||||
scanner *Scanner
|
||||
enricher *coverart.Enricher
|
||||
scanCfg RunScanConfig
|
||||
|
||||
mu sync.RWMutex
|
||||
cfg ScheduleConfig
|
||||
nextFire time.Time
|
||||
|
||||
refresh chan struct{}
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
func NewScheduler(pool *pgxpool.Pool, logger *slog.Logger,
|
||||
scanner *Scanner, enricher *coverart.Enricher,
|
||||
scanCfg RunScanConfig,
|
||||
) *Scheduler {
|
||||
return &Scheduler{
|
||||
pool: pool, logger: logger,
|
||||
scanner: scanner, enricher: enricher, scanCfg: scanCfg,
|
||||
refresh: make(chan struct{}, 1),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the scheduler's loop goroutine. The loop runs until
|
||||
// the passed ctx is cancelled or Stop is called.
|
||||
func (s *Scheduler) Start(ctx context.Context) {
|
||||
go s.loop(ctx)
|
||||
}
|
||||
|
||||
// Stop signals the loop to exit. Not idempotent — call only once
|
||||
// (typically at server shutdown).
|
||||
func (s *Scheduler) Stop() { close(s.stop) }
|
||||
|
||||
// Refresh signals the loop to reload config and recompute next-fire.
|
||||
// Non-blocking: additional Refresh calls coalesce on the buffered channel.
|
||||
func (s *Scheduler) Refresh() {
|
||||
select {
|
||||
case s.refresh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// NextFire returns the cached next-fire time. Zero when mode=off.
|
||||
func (s *Scheduler) NextFire() time.Time {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.nextFire
|
||||
}
|
||||
|
||||
func (s *Scheduler) setState(cfg ScheduleConfig, next time.Time) {
|
||||
s.mu.Lock()
|
||||
s.cfg = cfg
|
||||
s.nextFire = next
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Scheduler) loadConfig(ctx context.Context) (ScheduleConfig, error) {
|
||||
q := dbq.New(s.pool)
|
||||
row, err := q.GetScanSchedule(ctx)
|
||||
if err != nil {
|
||||
return ScheduleConfig{}, err
|
||||
}
|
||||
cfg := ScheduleConfig{Mode: row.Mode}
|
||||
if row.IntervalHours != nil {
|
||||
cfg.IntervalHours = int(*row.IntervalHours)
|
||||
}
|
||||
if row.TimeOfDay != nil {
|
||||
cfg.TimeOfDay = *row.TimeOfDay
|
||||
}
|
||||
if row.WeeklyDay != nil {
|
||||
cfg.WeeklyDay = int(*row.WeeklyDay)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) loop(ctx context.Context) {
|
||||
for {
|
||||
cfg, err := s.loadConfig(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("scheduler: load config failed", "err", err)
|
||||
retry := time.NewTimer(1 * time.Minute)
|
||||
select {
|
||||
case <-retry.C:
|
||||
case <-s.refresh:
|
||||
retry.Stop()
|
||||
case <-s.stop:
|
||||
retry.Stop()
|
||||
return
|
||||
case <-ctx.Done():
|
||||
retry.Stop()
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
next := cfg.NextFire(time.Now())
|
||||
s.setState(cfg, next)
|
||||
|
||||
if next.IsZero() {
|
||||
s.logger.Info("scheduler: mode=off, waiting for config change")
|
||||
select {
|
||||
case <-s.refresh:
|
||||
continue
|
||||
case <-s.stop:
|
||||
return
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
wait := time.Until(next)
|
||||
s.logger.Info("scheduler: next scan scheduled", "at", next, "in", wait)
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-timer.C:
|
||||
s.tryFire(ctx)
|
||||
case <-s.refresh:
|
||||
timer.Stop()
|
||||
case <-s.stop:
|
||||
timer.Stop()
|
||||
return
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) tryFire(ctx context.Context) {
|
||||
started, existing, err := TryStartScan(
|
||||
ctx, s.pool, s.scanner, s.enricher,
|
||||
s.logger.With("source", "scheduler"), s.scanCfg,
|
||||
)
|
||||
if err != nil {
|
||||
s.logger.Error("scheduler: try start failed", "err", err)
|
||||
return
|
||||
}
|
||||
if !started && existing != nil {
|
||||
s.logger.Info("scheduler: scan skipped — prior still in flight",
|
||||
"in_flight_id", existing.ID,
|
||||
"in_flight_started_at", existing.StartedAt.Time)
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNextFire_Off(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "off"}
|
||||
if got := c.NextFire(time.Now()); !got.IsZero() {
|
||||
t.Errorf("NextFire = %v, want zero time", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Interval(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "interval", IntervalHours: 6}
|
||||
now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 5, 6, 20, 0, 0, 0, time.UTC)
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Daily_Tomorrow(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "daily", TimeOfDay: "03:00"}
|
||||
now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 5, 7, 3, 0, 0, 0, time.UTC)
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Daily_Today(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "daily", TimeOfDay: "03:00"}
|
||||
now := time.Date(2026, 5, 6, 2, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 5, 6, 3, 0, 0, 0, time.UTC)
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Weekly_UpcomingDay(t *testing.T) {
|
||||
// Tuesday 2pm, schedule = Sunday (ISO 7) 03:00 → upcoming Sunday at 03:00.
|
||||
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 7, TimeOfDay: "03:00"}
|
||||
now := time.Date(2026, 5, 5, 14, 0, 0, 0, time.UTC) // 2026-05-05 = Tuesday
|
||||
want := time.Date(2026, 5, 10, 3, 0, 0, 0, time.UTC) // 2026-05-10 = Sunday
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Weekly_TodayBeforeTime(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 2, TimeOfDay: "03:00"} // ISO 2 = Tuesday
|
||||
now := time.Date(2026, 5, 5, 2, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 5, 5, 3, 0, 0, 0, time.UTC)
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_Weekly_TodayAfterTime(t *testing.T) {
|
||||
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 2, TimeOfDay: "03:00"}
|
||||
now := time.Date(2026, 5, 5, 14, 0, 0, 0, time.UTC)
|
||||
want := time.Date(2026, 5, 12, 3, 0, 0, 0, time.UTC)
|
||||
if got := c.NextFire(now); !got.Equal(want) {
|
||||
t.Errorf("NextFire = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextFire_InvalidConfigs(t *testing.T) {
|
||||
cases := []ScheduleConfig{
|
||||
{Mode: "interval", IntervalHours: 0},
|
||||
{Mode: "interval", IntervalHours: -1},
|
||||
{Mode: "daily", TimeOfDay: "bad"},
|
||||
{Mode: "daily", TimeOfDay: "25:00"},
|
||||
{Mode: "weekly", TimeOfDay: "03:00", WeeklyDay: 0},
|
||||
{Mode: "weekly", TimeOfDay: "03:00", WeeklyDay: 8},
|
||||
{Mode: "unknown"},
|
||||
}
|
||||
now := time.Now()
|
||||
for _, c := range cases {
|
||||
if got := c.NextFire(now); !got.IsZero() {
|
||||
t.Errorf("NextFire(%+v) = %v, want zero", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHHMM(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
h, m int
|
||||
wantOK bool
|
||||
}{
|
||||
{"03:00", 3, 0, true},
|
||||
{"23:59", 23, 59, true},
|
||||
{"00:00", 0, 0, true},
|
||||
{"24:00", 0, 0, false},
|
||||
{"23:60", 0, 0, false},
|
||||
{"3:00", 0, 0, false},
|
||||
{"03-00", 0, 0, false},
|
||||
{"ab:cd", 0, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
h, m, ok := parseHHMM(c.in)
|
||||
if ok != c.wantOK || (ok && (h != c.h || m != c.m)) {
|
||||
t.Errorf("parseHHMM(%q) = (%d, %d, %v), want (%d, %d, %v)",
|
||||
c.in, h, m, ok, c.h, c.m, c.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduler_Refresh_NonBlocking(t *testing.T) {
|
||||
s := &Scheduler{
|
||||
refresh: make(chan struct{}, 1),
|
||||
stop: make(chan struct{}),
|
||||
}
|
||||
s.Refresh()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.Refresh()
|
||||
s.Refresh()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Errorf("Refresh blocked when channel was full")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
)
|
||||
|
||||
// watcherDebounce is the quiet period after the last filesystem event before
|
||||
// the watcher processes the pending batch. Copying an album emits a burst of
|
||||
// write events; waiting for quiet coalesces the whole album into one scan.
|
||||
const watcherDebounce = 3 * time.Second
|
||||
|
||||
// watchAction is the pure decision for a single filesystem event.
|
||||
type watchAction int
|
||||
|
||||
const (
|
||||
watchIgnore watchAction = iota // not a relevant change
|
||||
watchEnqueueFile // an audio file was created/written
|
||||
watchScanDir // a directory appeared; scan it recursively
|
||||
)
|
||||
|
||||
// classifyEvent decides what to do with a filesystem event, separated from I/O
|
||||
// so it is unit-testable. Only Create/Write matter (removals are not pruned
|
||||
// here -- see the watcher's design non-goals). A newly-created directory is
|
||||
// scanned recursively because fsnotify does not emit events for files that
|
||||
// already existed inside a directory that was moved/copied in wholesale.
|
||||
func classifyEvent(op fsnotify.Op, isDir, isAudio bool) watchAction {
|
||||
if op&(fsnotify.Create|fsnotify.Write) == 0 {
|
||||
return watchIgnore
|
||||
}
|
||||
if isDir {
|
||||
if op&fsnotify.Create != 0 {
|
||||
return watchScanDir
|
||||
}
|
||||
return watchIgnore
|
||||
}
|
||||
if isAudio {
|
||||
return watchEnqueueFile
|
||||
}
|
||||
return watchIgnore
|
||||
}
|
||||
|
||||
// Watcher delivers near-instant library updates by watching the scan roots
|
||||
// with fsnotify and scanning just the changed files (plus inline cover-art
|
||||
// enrichment for the affected albums), instead of waiting for a full scan.
|
||||
// It is recursive: a watch is added per directory, and new directories get a
|
||||
// watch as they appear. inotify watch-limit exhaustion on huge libraries is
|
||||
// logged, not fatal -- the periodic safety-net scan covers anything missed.
|
||||
type Watcher struct {
|
||||
scanner *Scanner
|
||||
enricher *coverart.Enricher
|
||||
logger *slog.Logger
|
||||
roots []string
|
||||
debounce time.Duration
|
||||
}
|
||||
|
||||
func NewWatcher(
|
||||
scanner *Scanner, enricher *coverart.Enricher, logger *slog.Logger, roots []string,
|
||||
) *Watcher {
|
||||
return &Watcher{
|
||||
scanner: scanner,
|
||||
enricher: enricher,
|
||||
logger: logger,
|
||||
roots: roots,
|
||||
debounce: watcherDebounce,
|
||||
}
|
||||
}
|
||||
|
||||
// Run blocks until ctx is cancelled. Returns a non-nil error only when the
|
||||
// fsnotify watcher cannot be created at all; per-path watch failures and
|
||||
// fsnotify errors are logged and tolerated.
|
||||
func (w *Watcher) Run(ctx context.Context) error {
|
||||
if len(w.roots) == 0 {
|
||||
w.logger.Info("library watcher: no scan paths configured, watcher disabled")
|
||||
return nil
|
||||
}
|
||||
fsw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
w.logger.Error("library watcher: create failed", "err", err)
|
||||
return err
|
||||
}
|
||||
defer func() { _ = fsw.Close() }()
|
||||
|
||||
for _, root := range w.roots {
|
||||
w.addRecursive(fsw, root)
|
||||
}
|
||||
w.logger.Info("library watcher: started", "roots", len(w.roots))
|
||||
|
||||
batches := make(chan []string, batchQueueDepth)
|
||||
go w.processLoop(ctx, batches)
|
||||
w.eventLoop(ctx, fsw, batches)
|
||||
return nil
|
||||
}
|
||||
|
||||
// eventLoop reads fsnotify events, coalesces them via a debounce timer, and
|
||||
// hands drained batches to the process loop. Returns on ctx cancellation.
|
||||
func (w *Watcher) eventLoop(ctx context.Context, fsw *fsnotify.Watcher, batches chan<- []string) {
|
||||
pending := make(map[string]struct{})
|
||||
timer := time.NewTimer(w.debounce)
|
||||
timer.Stop()
|
||||
var timerC <-chan time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
w.logger.Info("library watcher: stopped")
|
||||
return
|
||||
case event, ok := <-fsw.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if w.handleEvent(fsw, event, pending) {
|
||||
timer.Reset(w.debounce)
|
||||
timerC = timer.C
|
||||
}
|
||||
case err, ok := <-fsw.Errors:
|
||||
if ok {
|
||||
w.logger.Warn("library watcher: fsnotify error", "err", err)
|
||||
}
|
||||
case <-timerC:
|
||||
timerC = nil
|
||||
paths := drainPending(pending)
|
||||
if len(paths) == 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case batches <- paths:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleEvent applies one event to the pending set and reports whether the
|
||||
// debounce timer should be (re)armed.
|
||||
func (w *Watcher) handleEvent(
|
||||
fsw *fsnotify.Watcher, event fsnotify.Event, pending map[string]struct{},
|
||||
) bool {
|
||||
info, err := os.Stat(event.Name)
|
||||
isDir := err == nil && info.IsDir()
|
||||
isAudio := audioExtensions[strings.ToLower(filepath.Ext(event.Name))]
|
||||
switch classifyEvent(event.Op, isDir, isAudio) {
|
||||
case watchScanDir:
|
||||
w.addRecursive(fsw, event.Name)
|
||||
return w.enqueueDirAudio(event.Name, pending)
|
||||
case watchEnqueueFile:
|
||||
pending[event.Name] = struct{}{}
|
||||
return true
|
||||
case watchIgnore:
|
||||
return false
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// processLoop serializes batch processing so a slow remote enrichment never
|
||||
// blocks the fsnotify event loop.
|
||||
func (w *Watcher) processLoop(ctx context.Context, batches <-chan []string) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case paths, ok := <-batches:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
w.process(ctx, paths)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// process scans the changed files then enriches just the affected albums.
|
||||
func (w *Watcher) process(ctx context.Context, paths []string) {
|
||||
changed, err := w.scanner.ScanFiles(ctx, paths)
|
||||
if err != nil {
|
||||
w.logger.Warn("library watcher: scan batch failed", "err", err)
|
||||
}
|
||||
if w.enricher != nil {
|
||||
for _, albumID := range changed {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if eerr := w.enricher.EnrichAlbum(ctx, albumID); eerr != nil {
|
||||
w.logger.Warn("library watcher: enrich album failed", "err", eerr)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(changed) > 0 {
|
||||
w.logger.Info("library watcher: applied", "files", len(paths), "albums", len(changed))
|
||||
}
|
||||
}
|
||||
|
||||
// addRecursive adds an fsnotify watch for root and every subdirectory under it.
|
||||
// Per-directory failures (inotify limits, permissions) are logged and skipped.
|
||||
func (w *Watcher) addRecursive(fsw *fsnotify.Watcher, root string) {
|
||||
_ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || !d.IsDir() {
|
||||
return nil //nolint:nilerr // tolerate unreadable subtrees
|
||||
}
|
||||
if aerr := fsw.Add(path); aerr != nil {
|
||||
w.logger.Warn("library watcher: add watch failed", "path", path, "err", aerr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// enqueueDirAudio enqueues every audio file under dir (used when a directory
|
||||
// appears wholesale, since fsnotify emits no events for its existing children).
|
||||
func (w *Watcher) enqueueDirAudio(dir string, pending map[string]struct{}) bool {
|
||||
added := false
|
||||
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil //nolint:nilerr // tolerate unreadable subtrees
|
||||
}
|
||||
if audioExtensions[strings.ToLower(filepath.Ext(path))] {
|
||||
pending[path] = struct{}{}
|
||||
added = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return added
|
||||
}
|
||||
|
||||
// drainPending returns the pending paths and clears the set.
|
||||
func drainPending(pending map[string]struct{}) []string {
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
paths := make([]string, 0, len(pending))
|
||||
for p := range pending {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
clear(pending)
|
||||
return paths
|
||||
}
|
||||
|
||||
// batchQueueDepth bounds how many debounced batches can be in flight before the
|
||||
// event loop would block; generous since each batch is processed quickly.
|
||||
const batchQueueDepth = 16
|
||||
@@ -0,0 +1,59 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
)
|
||||
|
||||
func TestClassifyEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op fsnotify.Op
|
||||
isDir bool
|
||||
isAudio bool
|
||||
want watchAction
|
||||
}{
|
||||
{"create audio file enqueues", fsnotify.Create, false, true, watchEnqueueFile},
|
||||
{"write audio file enqueues", fsnotify.Write, false, true, watchEnqueueFile},
|
||||
{"create non-audio file ignored", fsnotify.Create, false, false, watchIgnore},
|
||||
{"create directory scans it", fsnotify.Create, true, false, watchScanDir},
|
||||
{"write on directory ignored", fsnotify.Write, true, false, watchIgnore},
|
||||
{"remove audio file ignored (no pruning)", fsnotify.Remove, false, true, watchIgnore},
|
||||
{"rename audio file ignored", fsnotify.Rename, false, true, watchIgnore},
|
||||
{"chmod audio file ignored", fsnotify.Chmod, false, true, watchIgnore},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := classifyEvent(tt.op, tt.isDir, tt.isAudio); got != tt.want {
|
||||
t.Fatalf("classifyEvent(%v, dir=%v, audio=%v) = %v, want %v",
|
||||
tt.op, tt.isDir, tt.isAudio, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainPending(t *testing.T) {
|
||||
t.Run("empty set returns nil", func(t *testing.T) {
|
||||
if got := drainPending(map[string]struct{}{}); got != nil {
|
||||
t.Fatalf("drainPending(empty) = %v, want nil", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns paths and clears the set", func(t *testing.T) {
|
||||
pending := map[string]struct{}{
|
||||
"/music/a.mp3": {},
|
||||
"/music/b.flac": {},
|
||||
}
|
||||
got := drainPending(pending)
|
||||
sort.Strings(got)
|
||||
want := []string{"/music/a.mp3", "/music/b.flac"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("drainPending = %v, want %v", got, want)
|
||||
}
|
||||
if len(pending) != 0 {
|
||||
t.Fatalf("pending not cleared: %v", pending)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -180,6 +180,12 @@ func (w *Writer) RecordPlayEnded(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Idempotency for the client's durable close-by-id replay: if the
|
||||
// row is already closed, re-applying the close is harmless (the
|
||||
// UPDATE is last-write-wins) but a second skip_events insert must
|
||||
// NOT fire — a lost-response retry would otherwise double the
|
||||
// skip signal. See the 2026-06-11 contract audit.
|
||||
alreadyEnded := ev.EndedAt.Valid
|
||||
ratio := 0.0
|
||||
if track.DurationMs > 0 {
|
||||
ratio = float64(durationPlayedMs) / float64(track.DurationMs)
|
||||
@@ -196,7 +202,7 @@ func (w *Writer) RecordPlayEnded(
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if isSkip {
|
||||
if isSkip && !alreadyEnded {
|
||||
if _, err := q.InsertSkipEvent(ctx, dbq.InsertSkipEventParams{
|
||||
UserID: ev.UserID,
|
||||
TrackID: ev.TrackID,
|
||||
@@ -356,6 +362,24 @@ func (w *Writer) RecordOfflinePlay(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Idempotency: the offline queue is at-least-once, so a response lost
|
||||
// after commit makes the client re-fire the same play. The replayed
|
||||
// payload carries the original `at`, so an exact (user, track,
|
||||
// started_at) match means we already recorded it — no-op instead of a
|
||||
// duplicate history row. See the 2026-06-11 contract audit.
|
||||
var dupID pgtype.UUID
|
||||
err = tx.QueryRow(ctx,
|
||||
`SELECT id FROM play_events
|
||||
WHERE user_id = $1 AND track_id = $2 AND started_at = $3
|
||||
LIMIT 1`,
|
||||
userID, trackID, pgtype.Timestamptz{Time: at, Valid: true},
|
||||
).Scan(&dupID)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if err := w.autoClosePriorOpen(ctx, q, userID, at); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -471,10 +495,15 @@ func (w *Writer) captureSessionVector(
|
||||
}
|
||||
|
||||
// autoClosePriorOpen closes any open (ended_at IS NULL) play_event for the
|
||||
// user. Sets ended_at = at, duration_played_ms = min(at - started_at, track
|
||||
// duration), was_skipped = true. Skip rule is NOT applied — auto-closed
|
||||
// rows are flagged skipped regardless because we don't know what the user
|
||||
// actually heard.
|
||||
// user. Sets ended_at = at and duration_played_ms = min(at - started_at,
|
||||
// track duration) — our best estimate of how long the orphan played when
|
||||
// the client never sent play_ended (e.g. backgrounded mid-track). The same
|
||||
// skip rule as RecordPlayEnded is then applied to that estimate: an orphan
|
||||
// that sat open past the track's length reads as ratio ~1 → a real play, so
|
||||
// a fully-listened track that lost its close still lands in history instead
|
||||
// of being force-hidden. Deliberately writes no skip_events row — an
|
||||
// auto-close is an ambiguous signal, not a deliberate skip, and must not
|
||||
// feed the skip-ratio / recommendation signal.
|
||||
func (w *Writer) autoClosePriorOpen(
|
||||
ctx context.Context,
|
||||
q *dbq.Queries,
|
||||
@@ -503,6 +532,7 @@ func (w *Writer) autoClosePriorOpen(
|
||||
if track.DurationMs > 0 {
|
||||
ratio = float64(elapsedMs) / float64(track.DurationMs)
|
||||
}
|
||||
isSkip := ratio < w.skipMaxCompletionRatio && elapsedMs < w.skipMaxDurationPlayedMs
|
||||
ratioPtr := ratio
|
||||
durPtr := elapsedMs
|
||||
if _, err := q.UpdatePlayEventEnded(ctx, dbq.UpdatePlayEventEndedParams{
|
||||
@@ -510,11 +540,11 @@ func (w *Writer) autoClosePriorOpen(
|
||||
EndedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
DurationPlayedMs: &durPtr,
|
||||
CompletionRatio: &ratioPtr,
|
||||
WasSkipped: true,
|
||||
WasSkipped: isSkip,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
w.logger.Info("playevents: auto-closed prior open row",
|
||||
"play_event_id", prior.ID, "elapsed_ms", elapsedMs)
|
||||
"play_event_id", prior.ID, "elapsed_ms", elapsedMs, "was_skipped", isSkip)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -122,14 +122,32 @@ func TestRecordPlayStarted_AutoClosesPriorOpenRow(t *testing.T) {
|
||||
if !prior.EndedAt.Valid {
|
||||
t.Errorf("prior should be closed")
|
||||
}
|
||||
if !prior.WasSkipped {
|
||||
t.Errorf("prior should be marked was_skipped=true (auto-close convention)")
|
||||
// 45s elapsed on a 200s track clears the 30s duration threshold, so the
|
||||
// skip rule's AND fails -> NOT a skip. An orphan that played long enough
|
||||
// to count must still land in history rather than being force-hidden.
|
||||
if prior.WasSkipped {
|
||||
t.Errorf("auto-close at 45s should apply the skip rule -> was_skipped=false")
|
||||
}
|
||||
if prior.DurationPlayedMs == nil || *prior.DurationPlayedMs != 45_000 {
|
||||
t.Errorf("duration_played_ms = %v, want 45000", prior.DurationPlayedMs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPlayStarted_AutoCloseShortPlayIsSkip(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
t1 := time.Now().UTC()
|
||||
first, _ := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t1)
|
||||
// 5s elapsed: ratio 0.025 < 0.5 AND 5000 < 30000 -> both fail -> a skip.
|
||||
t2 := t1.Add(5 * time.Second)
|
||||
if _, err := f.w.RecordPlayStarted(context.Background(), f.user, f.track, "c", t2); err != nil {
|
||||
t.Fatalf("second: %v", err)
|
||||
}
|
||||
prior, _ := f.q.GetPlayEventByID(context.Background(), first.PlayEventID)
|
||||
if !prior.WasSkipped {
|
||||
t.Errorf("auto-close at 5s should be was_skipped=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPlayStarted_AutoCloseCapsAtTrackDuration(t *testing.T) {
|
||||
f := newFixture(t, 60_000) // 60s track
|
||||
t1 := time.Now().UTC()
|
||||
@@ -201,6 +219,55 @@ func TestRecordPlayEnded_DurationOver50Percent_NotSkip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A lost-response retry of an offline play (same user/track/started_at)
|
||||
// must not create a second history row. Guards the at-least-once replay
|
||||
// path added in the 2026-06-11 audit.
|
||||
func TestRecordOfflinePlay_DedupsByUserTrackStartedAt(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
ctx := context.Background()
|
||||
at := time.Now().UTC()
|
||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
|
||||
t.Fatalf("first offline: %v", err)
|
||||
}
|
||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
|
||||
t.Fatalf("replay offline: %v", err)
|
||||
}
|
||||
var count int
|
||||
if err := f.pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM play_events WHERE user_id=$1 AND track_id=$2 AND started_at=$3",
|
||||
f.user, f.track, pgtype.Timestamptz{Time: at, Valid: true}).Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("play_events count = %d, want 1 (deduped)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-closing an already-ended row (the client's durable close-by-id replay)
|
||||
// updates the row but must NOT insert a second skip_events row.
|
||||
func TestRecordPlayEnded_IdempotentClose_NoDuplicateSkipEvent(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
ctx := context.Background()
|
||||
t1 := time.Now().UTC()
|
||||
res, _ := f.w.RecordPlayStarted(ctx, f.user, f.track, "c", t1)
|
||||
t2 := t1.Add(10 * time.Second) // 5% / 10s -> skip
|
||||
if err := f.w.RecordPlayEnded(ctx, res.PlayEventID, 10_000, t2); err != nil {
|
||||
t.Fatalf("first end: %v", err)
|
||||
}
|
||||
if err := f.w.RecordPlayEnded(ctx, res.PlayEventID, 10_000, t2); err != nil {
|
||||
t.Fatalf("replay end: %v", err)
|
||||
}
|
||||
var count int
|
||||
if err := f.pool.QueryRow(ctx,
|
||||
"SELECT COUNT(*) FROM skip_events WHERE user_id=$1 AND track_id=$2",
|
||||
f.user, f.track).Scan(&count); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("skip_events count = %d, want 1 (idempotent close)", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPlaySkipped_AlwaysSkipFlagAndSkipEventRow(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
t1 := time.Now().UTC()
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||
)
|
||||
|
||||
// dailyRebuildHour is the local-time hour at which each user's system
|
||||
@@ -158,10 +159,7 @@ func (s *Scheduler) registerLocked(ctx context.Context, userID pgtype.UUID, tz s
|
||||
job, err := s.gocron.NewJob(
|
||||
gocron.CronJob(cronExpr, false /* withSeconds */),
|
||||
gocron.NewTask(func() {
|
||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, time.Now(), s.dataDir); err != nil {
|
||||
s.logger.Warn("scheduler: build failed",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
}
|
||||
s.rebuildUserDaily(ctx, userID, time.Now())
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
@@ -220,10 +218,22 @@ func (s *Scheduler) reconcile(ctx context.Context) {
|
||||
// whose scheduled fire was missed during downtime get caught up.
|
||||
func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiveUsersWithTimezonesRow) {
|
||||
for _, u := range users {
|
||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, u.ID, time.Now(), s.dataDir); err != nil {
|
||||
s.logger.Warn("scheduler: startup catch-up build failed",
|
||||
"user_id", uuidStringPL(u.ID), "err", err)
|
||||
}
|
||||
s.rebuildUserDaily(ctx, u.ID, time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// rebuildUserDaily recomputes the user's taste profile, then rebuilds their
|
||||
// system playlists + You-might-like rows. Taste runs first so the playlist
|
||||
// build can read a fresh profile (phase-2 consumption); both steps are
|
||||
// best-effort and a failure in one is logged without blocking the other.
|
||||
func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) {
|
||||
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, taste.DefaultConfig()); err != nil {
|
||||
s.logger.Warn("scheduler: taste profile rebuild failed",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
}
|
||||
if err := BuildSystemPlaylists(ctx, s.pool, s.logger, userID, now, s.dataDir); err != nil {
|
||||
s.logger.Warn("scheduler: build failed",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
@@ -147,6 +148,11 @@ var systemMixWeights = recommendation.ScoringWeights{
|
||||
JitterMagnitude: 0.1,
|
||||
ContextWeight: 0.5,
|
||||
SimilarityWeight: 1.5,
|
||||
// Taste profile (#796 phase 2): the daily mixes are the primary
|
||||
// taste-driven surface, so they lean on it. TasteMatchScore is in
|
||||
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
||||
// while passive avoidance (negative) gently demotes.
|
||||
TasteWeight: 1.5,
|
||||
}
|
||||
|
||||
// forYouHeadN is the number of top-scored tracks that anchor the For-You
|
||||
@@ -180,8 +186,21 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
|
||||
c recommendation.Candidate
|
||||
score float64
|
||||
}
|
||||
pairs := make([]scored, len(cands))
|
||||
for i, c := range cands {
|
||||
// Pin candidate order by track id before drawing jitter. The candidate
|
||||
// query (LoadRadioCandidatesV2) has ORDER BY random() arms and no
|
||||
// stable outer ordering, so DB row order varies call-to-call. Since
|
||||
// the i-th candidate gets the i-th seeded jitter draw, an unstable
|
||||
// input order would assign different jitter to the same track across
|
||||
// same-day rebuilds and reorder near-ties — breaking the daily
|
||||
// determinism this function promises. Sorting by id first makes the
|
||||
// jitter assignment a function of (track, day) alone.
|
||||
ordered := make([]recommendation.Candidate, len(cands))
|
||||
copy(ordered, cands)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
|
||||
})
|
||||
pairs := make([]scored, len(ordered))
|
||||
for i, c := range ordered {
|
||||
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)}
|
||||
}
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
@@ -310,6 +329,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
||||
TagOverlap: 60,
|
||||
LikesOverlap: 40,
|
||||
RandomFill: 150,
|
||||
// For-You / You-might-like are the taste-driven surfaces, so pull a
|
||||
// deep slice of the user's top taste-profile artists into the pool
|
||||
// (#796 phase 2b). Empty for cold-start users (no profile yet).
|
||||
TasteOverlap: 80,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -497,6 +520,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
||||
built = append(built, out...)
|
||||
}
|
||||
|
||||
// "You might like" Home rows reuse the same similarity engine but
|
||||
// persist to dedicated album/artist tables (not playlist_tracks).
|
||||
// Computed here (reads only) and atomic-replaced inside the tx below,
|
||||
// alongside the system playlists. A failed/gated computation is
|
||||
// handled by yml.built (leave prior rows vs. clear) — never fatal.
|
||||
yml := buildYouMightLike(ctx, q, logger, userID, dateStr, now)
|
||||
|
||||
// Atomic replace inside a transaction.
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -530,6 +560,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
|
||||
createdIDs = append(createdIDs, id)
|
||||
}
|
||||
|
||||
if yml.built {
|
||||
if err := persistYouMightLike(ctx, qtx, userID, yml); err != nil {
|
||||
buildErr = fmt.Errorf("persist you-might-like: %w", err)
|
||||
return buildErr
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
buildErr = fmt.Errorf("commit tx: %w", err)
|
||||
return buildErr
|
||||
@@ -695,6 +732,12 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
|
||||
// (pgtype.UUID's String method exists on some pgx versions but not others;
|
||||
// also the playlists package needs this independent of test helpers.)
|
||||
// Suffix `PL` distinguishes it from any test helper named uuidString.
|
||||
// uuidLessPL reports whether a sorts before b by raw 16-byte value. Used
|
||||
// to pin candidate order deterministically before jitter assignment.
|
||||
func uuidLessPL(a, b pgtype.UUID) bool {
|
||||
return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0
|
||||
}
|
||||
|
||||
func uuidStringPL(u pgtype.UUID) string {
|
||||
if !u.Valid {
|
||||
return "<nil>"
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// you_might_like.go builds the per-user "You might like" Home rows
|
||||
// (#790): in-library albums/artists the listener doesn't actively spin
|
||||
// but is predicted to enjoy. It reuses the For-You candidate engine
|
||||
// (similarity + like-weighted scoring) and rolls the per-track scores up
|
||||
// to album/artist granularity, gated on a minimum listening history so a
|
||||
// near-empty profile ships nothing rather than noise.
|
||||
//
|
||||
// Built in the same daily BuildSystemPlaylists pass and atomic-replaced
|
||||
// alongside the system playlists; read back by /api/home.
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
)
|
||||
|
||||
const (
|
||||
// Cold-start gate. Below this much *real* listening the similarity
|
||||
// roll-up degrades toward random fill, so You-might-like ships
|
||||
// nothing rather than arbitrary tiles. Both bars must clear: distinct
|
||||
// tracks rules out "played 3 songs on repeat," distinct artists rules
|
||||
// out "hammered one album." Conservative defaults; raise if early
|
||||
// rows feel random on thin libraries.
|
||||
youMightLikeMinDistinctTracks = 20
|
||||
youMightLikeMinDistinctArtists = 5
|
||||
|
||||
// Persisted depth — a few more than the rendered HomeYouMightLikeLimit
|
||||
// so the read-time cross-section dedup (vs Most Played / Rediscover)
|
||||
// has headroom before trimming.
|
||||
youMightLikeAlbumsN = 12
|
||||
youMightLikeArtistsN = 12
|
||||
|
||||
// One strongly-matched artist shouldn't fill the albums row.
|
||||
youMightLikeMaxAlbumsPerArtist = 2
|
||||
|
||||
// Aggregation: sum of an entity's top-K track scores. Favors albums/
|
||||
// artists with several good matches over a single outlier track.
|
||||
youMightLikeAggTopK = 3
|
||||
)
|
||||
|
||||
// youMightLikeResult carries one day's roll-up. built=false means the
|
||||
// computation failed and the caller must leave any existing rows intact;
|
||||
// built=true with nil slices means "explicitly empty" (cold-start gate or
|
||||
// no candidates) and the caller should clear the user's rows.
|
||||
type youMightLikeResult struct {
|
||||
albumIDs []pgtype.UUID
|
||||
artistIDs []pgtype.UUID
|
||||
built bool
|
||||
}
|
||||
|
||||
// buildYouMightLike computes the user's daily album/artist rolls. Reads
|
||||
// only — the caller persists inside the build tx. Never returns an error:
|
||||
// a failure logs and yields built=false so the prior day's rows survive.
|
||||
func buildYouMightLike(
|
||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||
userID pgtype.UUID, dateStr string, now time.Time,
|
||||
) youMightLikeResult {
|
||||
signal, err := q.CountListeningSignalForUser(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn("you-might-like: listening-signal query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return youMightLikeResult{built: false}
|
||||
}
|
||||
if signal.DistinctTracks < youMightLikeMinDistinctTracks ||
|
||||
signal.DistinctArtists < youMightLikeMinDistinctArtists {
|
||||
// Cold start: not enough listening to recommend from. built=true
|
||||
// clears any stale rows (normally none) and ships an empty row.
|
||||
return youMightLikeResult{built: true}
|
||||
}
|
||||
|
||||
seeds, err := q.PickTopPlayedTracksForUser(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn("you-might-like: seed query failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return youMightLikeResult{built: false}
|
||||
}
|
||||
seed := pickForYouSeedForDay(seeds, userID, dateStr)
|
||||
if !seed.Valid {
|
||||
return youMightLikeResult{built: true}
|
||||
}
|
||||
|
||||
zeroVec := recommendation.SessionVector{Seed: true}
|
||||
// You-might-like surfaces in-library artists the user does NOT actively
|
||||
// engage with, so it deliberately skips the taste_overlap arm — that arm
|
||||
// pulls top-taste (mostly already-played) artists, which would crowd the
|
||||
// pool with entities the read-time dedup then strips. For-You/radio keep it.
|
||||
ymlLimits := systemForYouSourceLimits()
|
||||
ymlLimits.TasteOverlap = 0
|
||||
cands, err := recommendation.LoadCandidatesFromSimilarity(
|
||||
ctx, q, userID, seed, 1, zeroVec,
|
||||
[]pgtype.UUID{seed}, ymlLimits,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Warn("you-might-like: candidate load failed; skipping",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
return youMightLikeResult{built: false}
|
||||
}
|
||||
|
||||
albumIDs, artistIDs := rollUpCandidates(cands, userID, dateStr, now)
|
||||
return youMightLikeResult{albumIDs: albumIDs, artistIDs: artistIDs, built: true}
|
||||
}
|
||||
|
||||
// scoredEntity is an album or artist with its aggregated score.
|
||||
type scoredEntity struct {
|
||||
id pgtype.UUID
|
||||
score float64
|
||||
}
|
||||
|
||||
// rollUpCandidates scores every candidate track (systemMixWeights, jitter
|
||||
// seeded by userIDHash so near-ties rotate day-to-day) and aggregates the
|
||||
// scores up to album and artist via sum-of-top-K. Returns the top-N album
|
||||
// and artist IDs, with a per-artist cap on the album list.
|
||||
func rollUpCandidates(
|
||||
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
|
||||
) (albumIDs, artistIDs []pgtype.UUID) {
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
albumScores := map[pgtype.UUID][]float64{}
|
||||
artistScores := map[pgtype.UUID][]float64{}
|
||||
albumArtist := map[pgtype.UUID]pgtype.UUID{}
|
||||
for _, c := range cands {
|
||||
s := recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)
|
||||
if c.Track.AlbumID.Valid {
|
||||
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
|
||||
albumArtist[c.Track.AlbumID] = c.Track.ArtistID
|
||||
}
|
||||
if c.Track.ArtistID.Valid {
|
||||
artistScores[c.Track.ArtistID] = append(artistScores[c.Track.ArtistID], s)
|
||||
}
|
||||
}
|
||||
|
||||
rankedAlbums := capAlbumsPerArtist(
|
||||
rankEntities(albumScores, dateStr), albumArtist, youMightLikeMaxAlbumsPerArtist)
|
||||
albumIDs = topNEntityIDs(rankedAlbums, youMightLikeAlbumsN)
|
||||
artistIDs = topNEntityIDs(rankEntities(artistScores, dateStr), youMightLikeArtistsN)
|
||||
return albumIDs, artistIDs
|
||||
}
|
||||
|
||||
// rankEntities aggregates each entity's track scores (sum-of-top-K) and
|
||||
// returns them sorted by score DESC, ties broken deterministically by
|
||||
// tieBreakHash(id, dateStr) so ordering is stable within a day.
|
||||
func rankEntities(scoresByEntity map[pgtype.UUID][]float64, dateStr string) []scoredEntity {
|
||||
out := make([]scoredEntity, 0, len(scoresByEntity))
|
||||
for id, scores := range scoresByEntity {
|
||||
out = append(out, scoredEntity{id: id, score: sumTopK(scores, youMightLikeAggTopK)})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].score != out[j].score {
|
||||
return out[i].score > out[j].score
|
||||
}
|
||||
return tieBreakHash(out[i].id, dateStr) < tieBreakHash(out[j].id, dateStr)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// sumTopK sorts scores DESC and sums the highest k.
|
||||
func sumTopK(scores []float64, k int) float64 {
|
||||
sort.Sort(sort.Reverse(sort.Float64Slice(scores)))
|
||||
sum := 0.0
|
||||
for i := 0; i < len(scores) && i < k; i++ {
|
||||
sum += scores[i]
|
||||
}
|
||||
return sum
|
||||
}
|
||||
|
||||
// capAlbumsPerArtist drops albums beyond maxPerArtist for any one artist,
|
||||
// preserving input (score) order.
|
||||
func capAlbumsPerArtist(
|
||||
albums []scoredEntity, albumArtist map[pgtype.UUID]pgtype.UUID, maxPerArtist int,
|
||||
) []scoredEntity {
|
||||
perArtist := map[pgtype.UUID]int{}
|
||||
out := make([]scoredEntity, 0, len(albums))
|
||||
for _, a := range albums {
|
||||
art := albumArtist[a.id]
|
||||
if art.Valid {
|
||||
if perArtist[art] >= maxPerArtist {
|
||||
continue
|
||||
}
|
||||
perArtist[art]++
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// topNEntityIDs truncates to n and projects the IDs in rank order.
|
||||
func topNEntityIDs(entities []scoredEntity, n int) []pgtype.UUID {
|
||||
if len(entities) > n {
|
||||
entities = entities[:n]
|
||||
}
|
||||
out := make([]pgtype.UUID, 0, len(entities))
|
||||
for _, e := range entities {
|
||||
out = append(out, e.id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// persistYouMightLike atomic-replaces the user's You-might-like rows
|
||||
// inside the build tx. Called only when the roll-up was freshly built;
|
||||
// a failed computation leaves the prior rows untouched.
|
||||
func persistYouMightLike(
|
||||
ctx context.Context, qtx *dbq.Queries, userID pgtype.UUID, r youMightLikeResult,
|
||||
) error {
|
||||
if err := qtx.DeleteYouMightLikeAlbumsForUser(ctx, userID); err != nil {
|
||||
return fmt.Errorf("delete you-might-like albums: %w", err)
|
||||
}
|
||||
for i, id := range r.albumIDs {
|
||||
if err := qtx.InsertYouMightLikeAlbum(ctx, dbq.InsertYouMightLikeAlbumParams{
|
||||
UserID: userID, AlbumID: id, Rank: int32(i),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("insert you-might-like album: %w", err)
|
||||
}
|
||||
}
|
||||
if err := qtx.DeleteYouMightLikeArtistsForUser(ctx, userID); err != nil {
|
||||
return fmt.Errorf("delete you-might-like artists: %w", err)
|
||||
}
|
||||
for i, id := range r.artistIDs {
|
||||
if err := qtx.InsertYouMightLikeArtist(ctx, dbq.InsertYouMightLikeArtistParams{
|
||||
UserID: userID, ArtistID: id, Rank: int32(i),
|
||||
}); err != nil {
|
||||
return fmt.Errorf("insert you-might-like artist: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package playlists_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||
)
|
||||
|
||||
// countYouMightLike returns the persisted row counts for a user.
|
||||
func countYouMightLike(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) (albums, artists int) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM you_might_like_albums WHERE user_id = $1`, userID,
|
||||
).Scan(&albums); err != nil {
|
||||
t.Fatalf("count albums: %v", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM you_might_like_artists WHERE user_id = $1`, userID,
|
||||
).Scan(&artists); err != nil {
|
||||
t.Fatalf("count artists: %v", err)
|
||||
}
|
||||
return albums, artists
|
||||
}
|
||||
|
||||
// TestYouMightLike_ColdStartGate: a thin profile (3 artists × 3 tracks =
|
||||
// 9 distinct tracks, below the 20-track / 5-artist gate) must produce no
|
||||
// You-might-like rows — the build ships nothing rather than near-random
|
||||
// tiles.
|
||||
func TestYouMightLike_ColdStartGate(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
u, _ := seedActiveLibrary(t, pool, "ymlcold", 3, 3)
|
||||
|
||||
if err := playlists.BuildSystemPlaylists(
|
||||
context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
|
||||
); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
albums, artists := countYouMightLike(t, pool, u.ID)
|
||||
if albums != 0 || artists != 0 {
|
||||
t.Errorf("cold-start user should have no you-might-like rows; got %d albums, %d artists",
|
||||
albums, artists)
|
||||
}
|
||||
}
|
||||
|
||||
// TestYouMightLike_SufficientActivityPopulates: a rich profile (8 artists
|
||||
// × 4 tracks = 32 distinct tracks, clearing both gate bars) populates the
|
||||
// You-might-like tables in the daily build.
|
||||
func TestYouMightLike_SufficientActivityPopulates(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
u, _ := seedActiveLibrary(t, pool, "ymlrich", 8, 4)
|
||||
|
||||
if err := playlists.BuildSystemPlaylists(
|
||||
context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
|
||||
); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
albums, artists := countYouMightLike(t, pool, u.ID)
|
||||
if albums == 0 {
|
||||
t.Error("rich user should have you-might-like album rows; got 0")
|
||||
}
|
||||
if artists == 0 {
|
||||
t.Error("rich user should have you-might-like artist rows; got 0")
|
||||
}
|
||||
}
|
||||
|
||||
// TestYouMightLike_AtomicReplace: a second build replaces rather than
|
||||
// appends, so counts stay bounded by the persisted depth.
|
||||
func TestYouMightLike_AtomicReplace(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
logger := discardLogger()
|
||||
u, _ := seedActiveLibrary(t, pool, "ymlreplace", 8, 4)
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := playlists.BuildSystemPlaylists(
|
||||
ctx, pool, logger, u.ID, time.Now().UTC(), t.TempDir(),
|
||||
); err != nil {
|
||||
t.Fatalf("build %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
albums, artists := countYouMightLike(t, pool, u.ID)
|
||||
if albums > 12 {
|
||||
t.Errorf("albums = %d after two builds, want <= 12 (atomic replace)", albums)
|
||||
}
|
||||
if artists > 12 {
|
||||
t.Errorf("artists = %d after two builds, want <= 12 (atomic replace)", artists)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
)
|
||||
|
||||
// fixedNow keeps recencyDecay stable across the pure roll-up tests.
|
||||
var fixedNow = time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// byteOf returns the per-test entity discriminator (makeCand sets it in
|
||||
// Bytes[15]) so assertions can name albums/artists by their small int.
|
||||
func byteOf(u pgtype.UUID) byte { return u.Bytes[15] }
|
||||
|
||||
// countWithByte reports how many of ids carry the given Bytes[15] marker.
|
||||
func countWithByte(ids []pgtype.UUID, want byte) int {
|
||||
n := 0
|
||||
for _, id := range ids {
|
||||
if byteOf(id) == want {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestSumTopK(t *testing.T) {
|
||||
if got := sumTopK([]float64{1, 2, 3, 4}, 2); got != 7 {
|
||||
t.Errorf("sumTopK top-2 of 1..4 = %v, want 7", got)
|
||||
}
|
||||
if got := sumTopK([]float64{5}, 3); got != 5 {
|
||||
t.Errorf("sumTopK fewer-than-k = %v, want 5", got)
|
||||
}
|
||||
if got := sumTopK(nil, 3); got != 0 {
|
||||
t.Errorf("sumTopK empty = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollUpCandidates_MultiMatchAlbumRanksFirst(t *testing.T) {
|
||||
// Album 10 (artist 100): three solid matches. Album 20 (artist 200):
|
||||
// one slightly-stronger single match. Sum-of-top-3 should rank the
|
||||
// multi-match album ahead of the single-match one. Similarity gaps are
|
||||
// wide enough that the ±0.1 jitter can't reorder the result.
|
||||
cands := []recommendation.Candidate{
|
||||
makeCand(1, 10, 100, 0.8),
|
||||
makeCand(2, 10, 100, 0.8),
|
||||
makeCand(3, 10, 100, 0.8),
|
||||
makeCand(4, 20, 200, 0.95),
|
||||
}
|
||||
albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow)
|
||||
if len(albums) < 2 {
|
||||
t.Fatalf("want >=2 albums, got %d", len(albums))
|
||||
}
|
||||
if byteOf(albums[0]) != 10 {
|
||||
t.Errorf("top album = %d, want 10 (multi-match beats single)", byteOf(albums[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollUpCandidates_PerArtistAlbumCap(t *testing.T) {
|
||||
// Artist 100 spans albums 10/11/12; the album row caps any one artist
|
||||
// at youMightLikeMaxAlbumsPerArtist (2). Artist 200's album 20 should
|
||||
// still appear.
|
||||
cands := []recommendation.Candidate{
|
||||
makeCand(1, 10, 100, 0.9),
|
||||
makeCand(2, 11, 100, 0.85),
|
||||
makeCand(3, 12, 100, 0.8),
|
||||
makeCand(4, 20, 200, 0.7),
|
||||
}
|
||||
albums, _ := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow)
|
||||
from100 := countWithByte(albums, 10) + countWithByte(albums, 11) + countWithByte(albums, 12)
|
||||
if from100 > youMightLikeMaxAlbumsPerArtist {
|
||||
t.Errorf("artist 100 contributed %d albums, want <= %d",
|
||||
from100, youMightLikeMaxAlbumsPerArtist)
|
||||
}
|
||||
if countWithByte(albums, 20) != 1 {
|
||||
t.Errorf("album 20 (artist 200) should survive the cap; got %d",
|
||||
countWithByte(albums, 20))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollUpCandidates_ArtistRollupDistinct(t *testing.T) {
|
||||
// Three artists; the artist roll-up should surface all three distinct
|
||||
// artist IDs (no per-artist cap on the artist row).
|
||||
cands := []recommendation.Candidate{
|
||||
makeCand(1, 10, 100, 0.9),
|
||||
makeCand(2, 11, 101, 0.6),
|
||||
makeCand(3, 12, 102, 0.3),
|
||||
}
|
||||
_, artists := rollUpCandidates(cands, testUserID, "2026-06-11", fixedNow)
|
||||
seen := map[byte]bool{}
|
||||
for _, a := range artists {
|
||||
seen[byteOf(a)] = true
|
||||
}
|
||||
for _, want := range []byte{100, 101, 102} {
|
||||
if !seen[want] {
|
||||
t.Errorf("artist %d missing from roll-up", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapAlbumsPerArtist_DropsBeyondCap(t *testing.T) {
|
||||
mk := func(albumN, artistN int) (pgtype.UUID, pgtype.UUID) {
|
||||
var al, ar pgtype.UUID
|
||||
al.Valid, ar.Valid = true, true
|
||||
al.Bytes[15], ar.Bytes[15] = byte(albumN), byte(artistN)
|
||||
return al, ar
|
||||
}
|
||||
a10, ar1 := mk(10, 1)
|
||||
a11, _ := mk(11, 1)
|
||||
a12, _ := mk(12, 1)
|
||||
a20, ar2 := mk(20, 2)
|
||||
albumArtist := map[pgtype.UUID]pgtype.UUID{a10: ar1, a11: ar1, a12: ar1, a20: ar2}
|
||||
in := []scoredEntity{{a10, 3}, {a11, 2}, {a12, 1}, {a20, 0.5}}
|
||||
got := capAlbumsPerArtist(in, albumArtist, 2)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3 (artist 1 capped at 2 + artist 2's one)", len(got))
|
||||
}
|
||||
// Highest-scored two of artist 1 (albums 10, 11) kept; 12 dropped.
|
||||
if byteOf(got[2].id) != 20 {
|
||||
t.Errorf("third survivor = %d, want 20", byteOf(got[2].id))
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,11 @@ func LoadCandidates(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile, err := LoadTasteProfile(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
@@ -52,6 +57,7 @@ func LoadCandidates(
|
||||
PlayCount: int(r.PlayCount),
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -66,6 +72,10 @@ type CandidateSourceLimits struct {
|
||||
TagOverlap int
|
||||
LikesOverlap int
|
||||
RandomFill int
|
||||
// TasteOverlap (#796 phase 2b): tracks by the user's top positively-
|
||||
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
|
||||
// users have an empty profile, so it contributes nothing anyway).
|
||||
TasteOverlap int
|
||||
}
|
||||
|
||||
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
||||
@@ -76,6 +86,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
TagOverlap: 20,
|
||||
LikesOverlap: 20,
|
||||
RandomFill: 30,
|
||||
TasteOverlap: 20,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +118,7 @@ func LoadCandidatesFromSimilarity(
|
||||
Limit_3: int32(limits.TagOverlap),
|
||||
Limit_4: int32(limits.LikesOverlap),
|
||||
Limit_5: int32(limits.RandomFill),
|
||||
Limit_6: int32(limits.TasteOverlap),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -117,6 +129,11 @@ func LoadCandidatesFromSimilarity(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profile, err := LoadTasteProfile(ctx, q, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
@@ -141,6 +158,7 @@ func LoadCandidatesFromSimilarity(
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
SimilarityScore: simScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -262,6 +262,61 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadCandidatesFromSimilarity_TasteOverlapArm (#796 phase 2b): a track by
|
||||
// a positively-weighted taste-profile artist enters the pool via taste_overlap
|
||||
// even with every other arm disabled; a negatively-weighted artist's track does
|
||||
// not (the WHERE weight > 0 filter).
|
||||
func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) {
|
||||
f := newFixture(t, 2) // seed + 1 other, both by the fixture artist
|
||||
seed := f.tracks[0]
|
||||
target := f.tracks[1]
|
||||
ctx := context.Background()
|
||||
|
||||
// Fixture artist gets a positive taste weight.
|
||||
if _, err := f.pool.Exec(ctx,
|
||||
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 5.0)`,
|
||||
f.user, seed.ArtistID); err != nil {
|
||||
t.Fatalf("insert taste (positive): %v", err)
|
||||
}
|
||||
|
||||
// A second artist with a NEGATIVE weight — its track must be excluded.
|
||||
negArtist, _ := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "NegArtist", SortName: "NegArtist"})
|
||||
negAlbum, _ := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "NegAlbum", SortTitle: "NegAlbum", ArtistID: negArtist.ID})
|
||||
negTrack, _ := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||
Title: "NegTrack", AlbumID: negAlbum.ID, ArtistID: negArtist.ID,
|
||||
FilePath: "/tmp/neg.flac", DurationMs: 180_000,
|
||||
})
|
||||
if _, err := f.pool.Exec(ctx,
|
||||
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, -3.0)`,
|
||||
f.user, negArtist.ID); err != nil {
|
||||
t.Fatalf("insert taste (negative): %v", err)
|
||||
}
|
||||
|
||||
// Only the taste_overlap arm is enabled.
|
||||
limits := CandidateSourceLimits{TasteOverlap: 10}
|
||||
got, err := LoadCandidatesFromSimilarity(
|
||||
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
var sawTarget, sawNeg bool
|
||||
for _, c := range got {
|
||||
switch c.Track.ID {
|
||||
case target.ID:
|
||||
sawTarget = true
|
||||
case negTrack.ID:
|
||||
sawNeg = true
|
||||
}
|
||||
}
|
||||
if !sawTarget {
|
||||
t.Error("positive-taste-artist track missing (taste_overlap arm didn't contribute)")
|
||||
}
|
||||
if sawNeg {
|
||||
t.Error("negative-taste-artist track present (weight > 0 filter failed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
|
||||
f := newFixture(t, 1) // just the seed
|
||||
seed := f.tracks[0]
|
||||
|
||||
@@ -34,6 +34,13 @@ const (
|
||||
// artist from dominating the row. Two is enough variety; higher
|
||||
// reads as "this artist's discography" instead of "rediscover".
|
||||
maxAlbumsPerArtistInRediscover = 2
|
||||
|
||||
// HomeYouMightLikeLimit is the rendered count for each You-might-like
|
||||
// row. The build persists a few more (youMightLikeAlbumsN/ArtistsN)
|
||||
// so the read-time cross-section dedup has headroom; this query asks
|
||||
// for the persisted depth and the Go layer trims to this after dedup.
|
||||
HomeYouMightLikeLimit = 10
|
||||
youMightLikeFetch = 12
|
||||
)
|
||||
|
||||
// HomePayload is the composite returned by HomeData. All slices are
|
||||
@@ -45,6 +52,8 @@ type HomePayload struct {
|
||||
RediscoverArtists []dbq.ListRediscoverArtistsForUserRow
|
||||
MostPlayedTracks []dbq.ListMostPlayedTracksForUserRow
|
||||
LastPlayedArtists []dbq.ListLastPlayedArtistsForUserRow
|
||||
YouMightLikeAlbums []dbq.ListYouMightLikeAlbumsForUserRow
|
||||
YouMightLikeArtists []dbq.ListYouMightLikeArtistsForUserRow
|
||||
}
|
||||
|
||||
// HomeData runs five queries in parallel and assembles the payload.
|
||||
@@ -77,9 +86,11 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
|
||||
RediscoverArtists: []dbq.ListRediscoverArtistsForUserRow{},
|
||||
MostPlayedTracks: []dbq.ListMostPlayedTracksForUserRow{},
|
||||
LastPlayedArtists: []dbq.ListLastPlayedArtistsForUserRow{},
|
||||
YouMightLikeAlbums: []dbq.ListYouMightLikeAlbumsForUserRow{},
|
||||
YouMightLikeArtists: []dbq.ListYouMightLikeArtistsForUserRow{},
|
||||
}
|
||||
|
||||
wg.Add(5)
|
||||
wg.Add(7)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
rows, err := q.ListRecentlyAddedAlbumsWithArtist(ctx, HomeRecentlyAddedLimit)
|
||||
@@ -129,6 +140,28 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
|
||||
}
|
||||
out.RediscoverArtists = rows
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
rows, err := q.ListYouMightLikeAlbumsForUser(ctx, dbq.ListYouMightLikeAlbumsForUserParams{
|
||||
UserID: userID, Limit: youMightLikeFetch,
|
||||
})
|
||||
if err != nil {
|
||||
fail("you_might_like_albums", err)
|
||||
return
|
||||
}
|
||||
out.YouMightLikeAlbums = rows
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
rows, err := q.ListYouMightLikeArtistsForUser(ctx, dbq.ListYouMightLikeArtistsForUserParams{
|
||||
UserID: userID, Limit: youMightLikeFetch,
|
||||
})
|
||||
if err != nil {
|
||||
fail("you_might_like_artists", err)
|
||||
return
|
||||
}
|
||||
out.YouMightLikeArtists = rows
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
if firstErr != nil {
|
||||
@@ -140,9 +173,155 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
|
||||
// the rediscover lists down toward HomeRediscoverLimit.
|
||||
out.RediscoverAlbums = applyRediscoverAlbumFilters(out.RediscoverAlbums, out.MostPlayedTracks)
|
||||
out.RediscoverArtists = applyRediscoverArtistFilters(out.RediscoverArtists, out.MostPlayedTracks)
|
||||
// You-might-like dedups against what the user already engages with
|
||||
// (Most Played) and against the other Home rows so a tile doesn't
|
||||
// appear twice. Trims to HomeYouMightLikeLimit after dedup.
|
||||
out.YouMightLikeAlbums = applyYouMightLikeAlbumFilters(
|
||||
out.YouMightLikeAlbums, out.MostPlayedTracks, out.RediscoverAlbums)
|
||||
out.YouMightLikeArtists = applyYouMightLikeArtistFilters(
|
||||
out.YouMightLikeArtists, out.MostPlayedTracks, out.RediscoverArtists, out.LastPlayedArtists)
|
||||
// The taste roll-up surfaces top-similar entities, which for a heavy
|
||||
// listener are mostly ones they already play — so the dedup above can
|
||||
// leave the section thin (often a single artist). Top up from the much
|
||||
// larger pool of liked entities, applying the same exclusions so the
|
||||
// fallback neither duplicates a tile nor suggests an actively-played one.
|
||||
if len(out.YouMightLikeArtists) < HomeYouMightLikeLimit {
|
||||
out.YouMightLikeArtists = topUpYouMightLikeArtists(ctx, q, userID, out)
|
||||
}
|
||||
if len(out.YouMightLikeAlbums) < HomeYouMightLikeLimit {
|
||||
out.YouMightLikeAlbums = topUpYouMightLikeAlbums(ctx, q, userID, out)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// youMightLikeFallbackFetch is the liked-entity pool the fallback draws from.
|
||||
// Generous so the dedup still leaves enough to fill the row.
|
||||
const youMightLikeFallbackFetch = 100
|
||||
|
||||
// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
|
||||
// artists the user has shown affinity for — explicit artist-likes PLUS the
|
||||
// artists of liked albums and liked tracks (most users like albums/tracks far
|
||||
// more than artists, so the broad pool is what actually fills the row).
|
||||
// Excludes what's already shown on Home (the section itself, Rediscover,
|
||||
// Most/Last Played) so the fallback neither duplicates a tile nor suggests an
|
||||
// actively-played artist. Best-effort: a query error leaves the section as-is.
|
||||
func topUpYouMightLikeArtists(
|
||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
||||
) []dbq.ListYouMightLikeArtistsForUserRow {
|
||||
excluded := mostPlayedArtistIDs(out.MostPlayedTracks)
|
||||
for _, r := range out.RediscoverArtists {
|
||||
excluded[r.Artist.ID] = struct{}{}
|
||||
}
|
||||
for _, r := range out.LastPlayedArtists {
|
||||
excluded[r.Artist.ID] = struct{}{}
|
||||
}
|
||||
result := out.YouMightLikeArtists
|
||||
for _, r := range result {
|
||||
excluded[r.Artist.ID] = struct{}{}
|
||||
}
|
||||
fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{
|
||||
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||
})
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for _, r := range fb {
|
||||
if _, dup := excluded[r.Artist.ID]; dup {
|
||||
continue
|
||||
}
|
||||
result = append(result, dbq.ListYouMightLikeArtistsForUserRow(r))
|
||||
if len(result) >= HomeYouMightLikeLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// topUpYouMightLikeAlbums is the album-row counterpart to
|
||||
// topUpYouMightLikeArtists, drawing from the user's liked albums.
|
||||
func topUpYouMightLikeAlbums(
|
||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
||||
) []dbq.ListYouMightLikeAlbumsForUserRow {
|
||||
excluded := mostPlayedAlbumIDs(out.MostPlayedTracks)
|
||||
for _, r := range out.RediscoverAlbums {
|
||||
excluded[r.Album.ID] = struct{}{}
|
||||
}
|
||||
result := out.YouMightLikeAlbums
|
||||
for _, r := range result {
|
||||
excluded[r.Album.ID] = struct{}{}
|
||||
}
|
||||
fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{
|
||||
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||
})
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
for _, r := range fb {
|
||||
if _, dup := excluded[r.Album.ID]; dup {
|
||||
continue
|
||||
}
|
||||
result = append(result, dbq.ListYouMightLikeAlbumsForUserRow(r))
|
||||
if len(result) >= HomeYouMightLikeLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// applyYouMightLikeAlbumFilters drops albums the user actively plays
|
||||
// (Most Played) or that already appear in Rediscover, then trims to
|
||||
// HomeYouMightLikeLimit. Build-time rank order is preserved.
|
||||
func applyYouMightLikeAlbumFilters(
|
||||
rows []dbq.ListYouMightLikeAlbumsForUserRow,
|
||||
mostPlayed []dbq.ListMostPlayedTracksForUserRow,
|
||||
rediscover []dbq.ListRediscoverAlbumsForUserRow,
|
||||
) []dbq.ListYouMightLikeAlbumsForUserRow {
|
||||
excluded := mostPlayedAlbumIDs(mostPlayed)
|
||||
for _, r := range rediscover {
|
||||
excluded[r.Album.ID] = struct{}{}
|
||||
}
|
||||
out := make([]dbq.ListYouMightLikeAlbumsForUserRow, 0, HomeYouMightLikeLimit)
|
||||
for _, r := range rows {
|
||||
if _, dup := excluded[r.Album.ID]; dup {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
if len(out) >= HomeYouMightLikeLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// applyYouMightLikeArtistFilters drops artists the user actively plays
|
||||
// (Most Played), recently played (Last Played), or that already appear in
|
||||
// Rediscover, then trims to HomeYouMightLikeLimit.
|
||||
func applyYouMightLikeArtistFilters(
|
||||
rows []dbq.ListYouMightLikeArtistsForUserRow,
|
||||
mostPlayed []dbq.ListMostPlayedTracksForUserRow,
|
||||
rediscover []dbq.ListRediscoverArtistsForUserRow,
|
||||
lastPlayed []dbq.ListLastPlayedArtistsForUserRow,
|
||||
) []dbq.ListYouMightLikeArtistsForUserRow {
|
||||
excluded := mostPlayedArtistIDs(mostPlayed)
|
||||
for _, r := range rediscover {
|
||||
excluded[r.Artist.ID] = struct{}{}
|
||||
}
|
||||
for _, r := range lastPlayed {
|
||||
excluded[r.Artist.ID] = struct{}{}
|
||||
}
|
||||
out := make([]dbq.ListYouMightLikeArtistsForUserRow, 0, HomeYouMightLikeLimit)
|
||||
for _, r := range rows {
|
||||
if _, dup := excluded[r.Artist.ID]; dup {
|
||||
continue
|
||||
}
|
||||
out = append(out, r)
|
||||
if len(out) >= HomeYouMightLikeLimit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mostPlayedAlbumIDs returns the set of album IDs whose tracks appear
|
||||
// in the Most Played row. Used to dedup Rediscover so it doesn't list
|
||||
// albums the user is actively spinning.
|
||||
|
||||
@@ -2,6 +2,7 @@ package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -10,6 +11,77 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// TestHomeData_YouMightLike_FallbackFillsFromLikedArtists: when the taste
|
||||
// roll-up is empty/thin (no you_might_like_artists rows here), the section
|
||||
// fills from the user's liked artists. Rediscover takes its share first; the
|
||||
// You-might-like fallback fills the rest disjointly so no artist is shown
|
||||
// twice. 20 liked artists, none played → Rediscover 10 + You-might-like 10.
|
||||
func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "home-yml-fallback")
|
||||
liked := make(map[pgtype.UUID]bool, 20)
|
||||
for i := 0; i < 20; i++ {
|
||||
a := seedArtist(t, pool, fmt.Sprintf("YmlArtist%02d", i), "")
|
||||
likeArtist(t, pool, user.ID, a.ID)
|
||||
liked[a.ID] = true
|
||||
}
|
||||
|
||||
got, err := HomeData(context.Background(), pool, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("HomeData: %v", err)
|
||||
}
|
||||
if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit {
|
||||
t.Fatalf("you-might-like artists = %d, want %d (filled by fallback)",
|
||||
len(got.YouMightLikeArtists), HomeYouMightLikeLimit)
|
||||
}
|
||||
inRediscover := map[pgtype.UUID]bool{}
|
||||
for _, r := range got.RediscoverArtists {
|
||||
inRediscover[r.Artist.ID] = true
|
||||
}
|
||||
for _, r := range got.YouMightLikeArtists {
|
||||
if !liked[r.Artist.ID] {
|
||||
t.Errorf("you-might-like artist %s not from the liked pool", r.Artist.Name)
|
||||
}
|
||||
if inRediscover[r.Artist.ID] {
|
||||
t.Errorf("artist %s shown in both Rediscover and You-might-like", r.Artist.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomeData_YouMightLike_FallbackFromLikedTrackArtists: the broad fallback
|
||||
// must surface the artists of LIKED TRACKS even with no explicit artist-likes
|
||||
// (most users like tracks/albums, rarely artists). 12 liked tracks by distinct
|
||||
// artists, no artist-likes, no plays → the artist row fills from them.
|
||||
func TestHomeData_YouMightLike_FallbackFromLikedTrackArtists(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "home-yml-trackartists")
|
||||
fromLiked := make(map[pgtype.UUID]bool, 12)
|
||||
for i := 0; i < 12; i++ {
|
||||
a := seedArtist(t, pool, fmt.Sprintf("TrkArtist%02d", i), "")
|
||||
al := seedAlbumForArtist(t, pool, a.ID, fmt.Sprintf("Alb%02d", i))
|
||||
tr := seedTrackOnAlbum(t, pool, al.ID, a.ID, fmt.Sprintf("Trk%02d", i))
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, tr.ID); err != nil {
|
||||
t.Fatalf("like track: %v", err)
|
||||
}
|
||||
fromLiked[a.ID] = true
|
||||
}
|
||||
|
||||
got, err := HomeData(context.Background(), pool, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("HomeData: %v", err)
|
||||
}
|
||||
if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit {
|
||||
t.Fatalf("you-might-like artists = %d, want %d (from liked-track artists)",
|
||||
len(got.YouMightLikeArtists), HomeYouMightLikeLimit)
|
||||
}
|
||||
for _, r := range got.YouMightLikeArtists {
|
||||
if !fromLiked[r.Artist.ID] {
|
||||
t.Errorf("artist %s not from the liked-track pool", r.Artist.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "home-newuser")
|
||||
|
||||
@@ -19,6 +19,11 @@ type ScoringInputs struct {
|
||||
SkipCount int // play_events with was_skipped=true
|
||||
ContextualMatchScore float64 // [0, 1]; 0 when no signal
|
||||
SimilarityScore float64 // [0, 1]; 0 when no signal (random fill)
|
||||
// TasteMatchScore is the candidate's fit to the user's learned taste
|
||||
// profile (#796 phase 2), in [-1, +1]: positive draws a track toward the
|
||||
// user's taste, negative reflects passive avoidance, 0 when there's no
|
||||
// profile signal (cold start / artist+tags absent from the profile).
|
||||
TasteMatchScore float64
|
||||
}
|
||||
|
||||
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||
@@ -31,6 +36,7 @@ type ScoringWeights struct {
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
}
|
||||
|
||||
// Score computes the weighted-shuffle score per spec §6:
|
||||
@@ -41,6 +47,7 @@ type ScoringWeights struct {
|
||||
// - skip_ratio * SkipPenalty
|
||||
// + contextual_match_score * ContextWeight
|
||||
// + similarity_score * SimilarityWeight
|
||||
// + taste_match_score * TasteWeight
|
||||
// + small_random_jitter
|
||||
//
|
||||
// Higher score = more likely to surface. rng is a function returning a
|
||||
@@ -55,6 +62,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64
|
||||
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
||||
s += in.ContextualMatchScore * w.ContextWeight
|
||||
s += in.SimilarityScore * w.SimilarityWeight
|
||||
s += in.TasteMatchScore * w.TasteWeight
|
||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Taste-match tuning. The taste profile (written by internal/taste) holds
|
||||
// signed, unbounded artist/tag weights; these scales squash them into a
|
||||
// bounded [-1, +1] match via tanh, so one outlier artist can't compress the
|
||||
// rest toward zero (as a per-user max-normalisation would). A weight at the
|
||||
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
|
||||
const (
|
||||
tasteArtistScale = 4.0
|
||||
tasteTagScale = 3.0
|
||||
tasteArtistShare = 0.7
|
||||
tasteTagShare = 0.3
|
||||
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
||||
)
|
||||
|
||||
// TasteProfile is the read-side view of a user's learned taste: signed
|
||||
// weights over artists and genre tags. The zero value (and any unknown
|
||||
// artist/tag) contributes 0, so cold-start users get no taste effect.
|
||||
type TasteProfile struct {
|
||||
artists map[pgtype.UUID]float64
|
||||
tags map[string]float64
|
||||
}
|
||||
|
||||
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
|
||||
// tables (written daily by internal/taste). Returns an empty profile with no
|
||||
// error when the user has none.
|
||||
func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (TasteProfile, error) {
|
||||
arts, err := q.ListTasteProfileArtistsForUser(ctx, dbq.ListTasteProfileArtistsForUserParams{
|
||||
UserID: userID, Limit: tasteProfileLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
tags, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{
|
||||
UserID: userID, Limit: tasteProfileLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
p := TasteProfile{
|
||||
artists: make(map[pgtype.UUID]float64, len(arts)),
|
||||
tags: make(map[string]float64, len(tags)),
|
||||
}
|
||||
for _, a := range arts {
|
||||
p.artists[a.ArtistID] = a.Weight
|
||||
}
|
||||
for _, t := range tags {
|
||||
p.tags[t.Tag] = t.Weight
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
|
||||
// the artist's taste weight and the average of its genre tags' weights, each
|
||||
// tanh-squashed. Absent artist/tags contribute 0.
|
||||
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
|
||||
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
|
||||
|
||||
var tg float64
|
||||
if genre != nil {
|
||||
tags := splitGenres(*genre)
|
||||
if len(tags) > 0 {
|
||||
var sum float64
|
||||
for _, t := range tags {
|
||||
sum += p.tags[t]
|
||||
}
|
||||
tg = math.Tanh((sum / float64(len(tags))) / tasteTagScale)
|
||||
}
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg)
|
||||
}
|
||||
|
||||
// clampUnit constrains x to [-1, 1].
|
||||
func clampUnit(x float64) float64 {
|
||||
if x < -1 {
|
||||
return -1
|
||||
}
|
||||
if x > 1 {
|
||||
return 1
|
||||
}
|
||||
return x
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func uuidN(n byte) pgtype.UUID {
|
||||
return pgtype.UUID{Bytes: [16]byte{15: n}, Valid: true}
|
||||
}
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func TestTasteProfile_Match(t *testing.T) {
|
||||
loved := uuidN(1)
|
||||
disliked := uuidN(2)
|
||||
unknown := uuidN(3)
|
||||
p := TasteProfile{
|
||||
artists: map[pgtype.UUID]float64{loved: 8.0, disliked: -8.0},
|
||||
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0},
|
||||
}
|
||||
|
||||
if m := p.Match(loved, strPtr("Jazz")); m <= 0.5 {
|
||||
t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m)
|
||||
}
|
||||
if m := p.Match(disliked, strPtr("Noise")); m >= -0.5 {
|
||||
t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m)
|
||||
}
|
||||
if m := p.Match(unknown, nil); m != 0 {
|
||||
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
|
||||
}
|
||||
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
||||
// clearly positive.
|
||||
if m := p.Match(loved, strPtr("Unheard")); m <= 0 {
|
||||
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
|
||||
}
|
||||
// Output stays within [-1, 1] even with saturated inputs.
|
||||
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
|
||||
m := p.Match(a, strPtr("Jazz"))
|
||||
if m < -1 || m > 1 {
|
||||
t.Errorf("Match out of [-1,1]: %.3f", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
|
||||
var p TasteProfile // zero value: nil maps
|
||||
if m := p.Match(uuidN(1), strPtr("Jazz")); m != 0 {
|
||||
t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScore_TasteTermAddsAndSubtracts(t *testing.T) {
|
||||
now := time.Now()
|
||||
zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude
|
||||
w := ScoringWeights{TasteWeight: 2.0} // all other weights 0
|
||||
|
||||
pos := Score(ScoringInputs{TasteMatchScore: 1.0}, w, now, zeroJitter)
|
||||
if !almostEq(pos, 2.0) {
|
||||
t.Errorf("positive taste: Score = %.3f, want 2.0", pos)
|
||||
}
|
||||
neg := Score(ScoringInputs{TasteMatchScore: -1.0}, w, now, zeroJitter)
|
||||
if !almostEq(neg, -2.0) {
|
||||
t.Errorf("negative taste: Score = %.3f, want -2.0 (demotes)", neg)
|
||||
}
|
||||
off := Score(ScoringInputs{TasteMatchScore: 1.0}, ScoringWeights{}, now, zeroJitter)
|
||||
if !almostEq(off, 0.0) {
|
||||
t.Errorf("TasteWeight 0: Score = %.3f, want 0 (no effect)", off)
|
||||
}
|
||||
}
|
||||
|
||||
func almostEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
|
||||
|
||||
// TestLoadTasteProfile_RoundTrip seeds taste_profile rows and verifies the
|
||||
// reader hydrates them into a profile that scores a matching track positively.
|
||||
func TestLoadTasteProfile_RoundTrip(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
u := seedUser(t, pool, "taste-rt")
|
||||
art := seedArtist(t, pool, "Loved Artist", "")
|
||||
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)`,
|
||||
u.ID, art.ID, 8.0); err != nil {
|
||||
t.Fatalf("seed taste artist: %v", err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx,
|
||||
`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)`,
|
||||
u.ID, "Jazz", 6.0); err != nil {
|
||||
t.Fatalf("seed taste tag: %v", err)
|
||||
}
|
||||
|
||||
p, err := LoadTasteProfile(ctx, dbq.New(pool), u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if m := p.Match(art.ID, strPtr("Jazz")); m <= 0.5 {
|
||||
t.Errorf("round-trip Match = %.3f, want strongly positive", m)
|
||||
}
|
||||
if m := p.Match(uuidN(9), nil); m != 0 {
|
||||
t.Errorf("absent artist Match = %.3f, want 0", m)
|
||||
}
|
||||
}
|
||||
@@ -78,9 +78,8 @@ type Server struct {
|
||||
CoverSettings *coverart.SettingsService
|
||||
LibraryScanner *library.Scanner
|
||||
ScanCfg library.RunScanConfig
|
||||
Scheduler *library.Scheduler
|
||||
// Bus is the live-event bus shared with background workers (the
|
||||
// lidarr reconciler, scan scheduler) constructed in cmd/minstrel/main.go.
|
||||
// lidarr reconciler, scan workers) constructed in cmd/minstrel/main.go.
|
||||
// When nil, Router() constructs a local fallback (test contexts).
|
||||
Bus *eventbus.Bus
|
||||
// PlaylistScheduler fires per-user daily system-playlist builds at
|
||||
@@ -97,8 +96,8 @@ type Server struct {
|
||||
StreamSecret []byte
|
||||
}
|
||||
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig, scheduler *library.Scheduler) *Server {
|
||||
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg, Scheduler: scheduler}
|
||||
func New(logger *slog.Logger, pool *pgxpool.Pool, scanner ScanTrigger, subCfg subsonic.Config, eventsCfg config.EventsConfig, recCfg config.RecommendationConfig, dataDir string, brandingCfg config.BrandingConfig, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, libraryScanner *library.Scanner, scanCfg library.RunScanConfig) *Server {
|
||||
return &Server{Logger: logger, Pool: pool, Scanner: scanner, SubsonicCfg: subCfg, EventsCfg: eventsCfg, RecommendationCfg: recCfg, DataDir: dataDir, BrandingCfg: brandingCfg, CoverEnricher: coverEnricher, CoverSettings: coverSettings, LibraryScanner: libraryScanner, ScanCfg: scanCfg}
|
||||
}
|
||||
|
||||
func (s *Server) Router() http.Handler {
|
||||
@@ -144,7 +143,7 @@ func (s *Server) Router() http.Handler {
|
||||
if bus == nil {
|
||||
bus = eventbus.New()
|
||||
}
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.Scheduler, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
|
||||
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
|
||||
// /api/admin/scan is the only admin route owned by the server package
|
||||
// (it needs the Scanner). Register it as a single inline-middleware
|
||||
// route — using r.Route("/api/admin", ...) here would create a second
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
)
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestHealthz_IncludesMinClientVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestRouter_ServesSPAAtRoot(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -111,7 +111,7 @@ func TestRouter_DeepLinkFallbackReturnsSPA(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestRouter_APIPathNotSwallowedBySPA(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_RestPathNotSwallowedBySPA(t *testing.T) {
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
@@ -206,7 +206,7 @@ func TestRouter_AdminSubtreeNotShadowed(t *testing.T) {
|
||||
}
|
||||
|
||||
s := New(slog.New(slog.NewTextHandler(io.Discard, nil)), pool,
|
||||
stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{}, nil)
|
||||
stubScanner{}, subsonic.Config{}, config.EventsConfig{}, config.RecommendationConfig{}, "", config.BrandingConfig{}, nil, nil, nil, library.RunScanConfig{})
|
||||
ts := httptest.NewServer(s.Router())
|
||||
defer ts.Close()
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Package taste builds a persistent, decaying per-user taste profile (#796).
|
||||
// engagement.go is the pure core: it turns a single play's completion ratio
|
||||
// into a graded taste signal in [-1, +1], replacing the binary was_skipped
|
||||
// for learning purposes (was_skipped stays as-is for History display).
|
||||
package taste
|
||||
|
||||
import "math"
|
||||
|
||||
// EngagementParams tune the completion→engagement curve. All in [0,1] and
|
||||
// ordered HardSkip < NeutralCompletion < FullCompletion.
|
||||
type EngagementParams struct {
|
||||
// HardSkip: completion at/below which a play is a strong reject (-1).
|
||||
HardSkip float64
|
||||
// NeutralCompletion: completion at which a play is neutral (0). Set low
|
||||
// (~0.30) because people enjoy plenty of tracks they don't finish; only
|
||||
// genuinely-early abandons read as negative.
|
||||
NeutralCompletion float64
|
||||
// FullCompletion: completion at/above which a play is a full positive
|
||||
// (+1).
|
||||
FullCompletion float64
|
||||
}
|
||||
|
||||
// DefaultEngagementParams returns the tuned starting curve.
|
||||
func DefaultEngagementParams() EngagementParams {
|
||||
return EngagementParams{HardSkip: 0.05, NeutralCompletion: 0.30, FullCompletion: 0.90}
|
||||
}
|
||||
|
||||
// Engagement maps a play's completion ratio to a taste signal in [-1, +1]
|
||||
// via two linear ramps meeting at the neutral point:
|
||||
//
|
||||
// completion <= HardSkip → -1
|
||||
// HardSkip < c < Neutral → ramp -1 → 0
|
||||
// Neutral <= c < Full → ramp 0 → +1
|
||||
// completion >= Full → +1
|
||||
//
|
||||
// Completion is clamped to [0,1]. Degenerate params (non-increasing
|
||||
// thresholds) collapse the affected ramp to its endpoint rather than
|
||||
// dividing by zero.
|
||||
func Engagement(completion float64, p EngagementParams) float64 {
|
||||
c := clamp01(completion)
|
||||
if c <= p.HardSkip {
|
||||
return -1
|
||||
}
|
||||
if c < p.NeutralCompletion {
|
||||
span := p.NeutralCompletion - p.HardSkip
|
||||
if span <= 0 {
|
||||
return 0
|
||||
}
|
||||
return -1 + (c-p.HardSkip)/span
|
||||
}
|
||||
if c < p.FullCompletion {
|
||||
span := p.FullCompletion - p.NeutralCompletion
|
||||
if span <= 0 {
|
||||
return 1
|
||||
}
|
||||
return (c - p.NeutralCompletion) / span
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// decay is the exponential time-decay applied to a play's engagement:
|
||||
// 1.0 at age 0, 0.5 at age == halfLifeDays. Negative ages clamp to 0.
|
||||
func decay(ageDays, halfLifeDays float64) float64 {
|
||||
if ageDays < 0 {
|
||||
ageDays = 0
|
||||
}
|
||||
if halfLifeDays <= 0 {
|
||||
return 1
|
||||
}
|
||||
return math.Exp(-math.Ln2 * ageDays / halfLifeDays)
|
||||
}
|
||||
|
||||
func clamp01(x float64) float64 {
|
||||
if x < 0 {
|
||||
return 0
|
||||
}
|
||||
if x > 1 {
|
||||
return 1
|
||||
}
|
||||
return x
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package taste
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func almostEqual(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
|
||||
|
||||
func TestEngagement_Curve(t *testing.T) {
|
||||
p := DefaultEngagementParams() // HardSkip 0.05, Neutral 0.30, Full 0.90
|
||||
cases := []struct {
|
||||
name string
|
||||
completion float64
|
||||
want float64
|
||||
}{
|
||||
{"instant skip", 0.00, -1},
|
||||
{"at hard-skip boundary", 0.05, -1},
|
||||
{"neg ramp midpoint", 0.175, -0.5}, // (0.175-0.05)/(0.30-0.05)=0.5 → -0.5
|
||||
{"neutral point", 0.30, 0},
|
||||
{"pos ramp midpoint", 0.60, 0.5}, // (0.60-0.30)/(0.90-0.30)=0.5
|
||||
{"at full boundary", 0.90, 1},
|
||||
{"complete", 1.00, 1},
|
||||
{"clamp above 1", 1.50, 1},
|
||||
{"clamp below 0", -0.5, -1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Engagement(c.completion, p); !almostEqual(got, c.want) {
|
||||
t.Errorf("%s: Engagement(%.3f) = %.4f, want %.4f", c.name, c.completion, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngagement_MonotonicNonDecreasing(t *testing.T) {
|
||||
p := DefaultEngagementParams()
|
||||
prev := math.Inf(-1)
|
||||
for i := 0; i <= 100; i++ {
|
||||
c := float64(i) / 100
|
||||
got := Engagement(c, p)
|
||||
if got < prev-1e-9 {
|
||||
t.Fatalf("engagement decreased at completion %.2f: %.4f < %.4f", c, got, prev)
|
||||
}
|
||||
prev = got
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngagement_DegenerateParamsStayInRange(t *testing.T) {
|
||||
// Non-increasing thresholds must collapse ramps (no divide-by-zero) and
|
||||
// still produce a value in [-1, 1].
|
||||
p := EngagementParams{HardSkip: 0.5, NeutralCompletion: 0.5, FullCompletion: 0.5}
|
||||
for _, c := range []float64{0, 0.25, 0.5, 0.75, 1} {
|
||||
got := Engagement(c, p)
|
||||
if got < -1 || got > 1 {
|
||||
t.Errorf("Engagement(%.2f) = %.3f out of [-1,1]", c, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecay_HalfLife(t *testing.T) {
|
||||
if got := decay(0, 75); !almostEqual(got, 1) {
|
||||
t.Errorf("decay(0) = %.4f, want 1", got)
|
||||
}
|
||||
if got := decay(75, 75); !almostEqual(got, 0.5) {
|
||||
t.Errorf("decay(halflife) = %.4f, want 0.5", got)
|
||||
}
|
||||
if got := decay(150, 75); !almostEqual(got, 0.25) {
|
||||
t.Errorf("decay(2*halflife) = %.4f, want 0.25", got)
|
||||
}
|
||||
if got := decay(-5, 75); !almostEqual(got, 1) {
|
||||
t.Errorf("decay(negative age) = %.4f, want 1 (clamped)", got)
|
||||
}
|
||||
if got := decay(10, 0); !almostEqual(got, 1) {
|
||||
t.Errorf("decay(halflife=0) = %.4f, want 1 (guarded)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
// profile.go assembles the per-user taste profile from graded play
|
||||
// engagement (decayed over time) plus explicit-like bonuses, and
|
||||
// atomic-replaces the taste_profile_* tables. Phase 1 only builds the
|
||||
// object; candidate sourcing + scoring consume it in phase 2.
|
||||
package taste
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
|
||||
// starting values; wiring these into the server RecommendationConfig is a
|
||||
// later follow-up (phase 1 uses the defaults).
|
||||
type Config struct {
|
||||
Engagement EngagementParams
|
||||
|
||||
// HalfLifeDays: a play's engagement halves every this-many days, so the
|
||||
// profile tracks drift (recent behaviour dominates). WindowDays bounds
|
||||
// the plays fetched — beyond ~3 half-lives the decayed contribution is
|
||||
// negligible, so older plays are skipped for cost, not correctness.
|
||||
HalfLifeDays float64
|
||||
WindowDays float64
|
||||
|
||||
// Explicit-like bonuses. Likes stay the strongest single positive.
|
||||
ArtistLikeBonus float64
|
||||
TrackLikeBonus float64
|
||||
TagLikeBonus float64
|
||||
|
||||
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
|
||||
// go. Aggregation already protects an artist the user likes (one skip
|
||||
// nets out against many good plays); the floor additionally bounds the
|
||||
// magnitude so a heavily-skipped entity can't dominate as an extreme
|
||||
// negative.
|
||||
ArtistFloor float64
|
||||
TagFloor float64
|
||||
|
||||
// WeightEpsilon: entities with |weight| below this are dropped as noise.
|
||||
WeightEpsilon float64
|
||||
|
||||
// Size caps (guard rails for pathological libraries). Persist the top
|
||||
// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags.
|
||||
MaxArtists int
|
||||
MaxNegArtists int
|
||||
MaxTags int
|
||||
MaxNegTags int
|
||||
}
|
||||
|
||||
// DefaultConfig returns the tuned starting configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Engagement: DefaultEngagementParams(),
|
||||
HalfLifeDays: 75,
|
||||
WindowDays: 270,
|
||||
ArtistLikeBonus: 3.0,
|
||||
TrackLikeBonus: 1.0,
|
||||
TagLikeBonus: 0.5,
|
||||
ArtistFloor: -3.0,
|
||||
TagFloor: -3.0,
|
||||
WeightEpsilon: 0.05,
|
||||
MaxArtists: 500,
|
||||
MaxNegArtists: 150,
|
||||
MaxTags: 200,
|
||||
MaxNegTags: 60,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildTasteProfile recomputes and atomic-replaces the user's taste profile.
|
||||
// Reads the decay-windowed play engagement + explicit likes, accumulates
|
||||
// signed artist/tag weights, applies floor damping + size caps, and persists.
|
||||
// Self-contained transaction for the replace. Returns an error only on a
|
||||
// fatal DB failure; the daily caller logs and proceeds to playlist building
|
||||
// regardless (best-effort).
|
||||
//
|
||||
// The decay reference time is the database clock (age is computed in SQL via
|
||||
// now()), so no Go-side timestamp is threaded here; a phase-3 context model
|
||||
// that needs a pinned reference time would add one.
|
||||
func BuildTasteProfile(
|
||||
ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger,
|
||||
userID pgtype.UUID, cfg Config,
|
||||
) error {
|
||||
q := dbq.New(pool)
|
||||
|
||||
artistW, tagW, err := accumulate(ctx, q, userID, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyFloor(artistW, cfg.ArtistFloor)
|
||||
applyFloor(tagW, cfg.TagFloor)
|
||||
|
||||
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
|
||||
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags,
|
||||
func(a, b string) bool { return a < b })
|
||||
|
||||
if err := persist(ctx, pool, userID, artists, tags); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Debug("taste: profile rebuilt",
|
||||
"user_id", uuidString(userID), "artists", len(artists), "tags", len(tags))
|
||||
return nil
|
||||
}
|
||||
|
||||
// accumulate sums decayed play engagement and like bonuses into artist/tag
|
||||
// weight maps.
|
||||
func accumulate(
|
||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
|
||||
) (map[pgtype.UUID]float64, map[string]float64, error) {
|
||||
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
|
||||
UserID: userID,
|
||||
Column2: cfg.WindowDays,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
|
||||
}
|
||||
artistW := make(map[pgtype.UUID]float64)
|
||||
tagW := make(map[string]float64)
|
||||
for _, p := range plays {
|
||||
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
|
||||
artistW[p.ArtistID] += e
|
||||
for _, tag := range splitGenres(p.Genre) {
|
||||
tagW[tag] += e
|
||||
}
|
||||
}
|
||||
|
||||
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||
}
|
||||
for _, lt := range likedTracks {
|
||||
artistW[lt.ArtistID] += cfg.TrackLikeBonus
|
||||
for _, tag := range splitGenres(lt.Genre) {
|
||||
tagW[tag] += cfg.TagLikeBonus
|
||||
}
|
||||
}
|
||||
|
||||
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
|
||||
}
|
||||
for _, aid := range likedArtists {
|
||||
artistW[aid] += cfg.ArtistLikeBonus
|
||||
}
|
||||
return artistW, tagW, nil
|
||||
}
|
||||
|
||||
// persist atomic-replaces the user's profile rows inside one transaction.
|
||||
func persist(
|
||||
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||
artists []weighted[pgtype.UUID], tags []weighted[string],
|
||||
) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("taste: begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
qtx := dbq.New(tx)
|
||||
|
||||
if err := qtx.DeleteTasteProfileArtistsForUser(ctx, userID); err != nil {
|
||||
return fmt.Errorf("taste: delete artists: %w", err)
|
||||
}
|
||||
for _, a := range artists {
|
||||
if err := qtx.InsertTasteProfileArtist(ctx, dbq.InsertTasteProfileArtistParams{
|
||||
UserID: userID, ArtistID: a.Key, Weight: a.Weight,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("taste: insert artist: %w", err)
|
||||
}
|
||||
}
|
||||
if err := qtx.DeleteTasteProfileTagsForUser(ctx, userID); err != nil {
|
||||
return fmt.Errorf("taste: delete tags: %w", err)
|
||||
}
|
||||
for _, t := range tags {
|
||||
if err := qtx.InsertTasteProfileTag(ctx, dbq.InsertTasteProfileTagParams{
|
||||
UserID: userID, Tag: t.Key, Weight: t.Weight,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("taste: insert tag: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("taste: commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// weighted is a key with its signed taste weight.
|
||||
type weighted[K comparable] struct {
|
||||
Key K
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// rankWeighted drops near-zero entries, sorts by weight DESC (ties broken by
|
||||
// keyLess for determinism), and caps to the top topN plus the most-negative
|
||||
// botN so both ends of the signal survive without unbounded table growth.
|
||||
func rankWeighted[K comparable](
|
||||
m map[K]float64, eps float64, topN, botN int, keyLess func(a, b K) bool,
|
||||
) []weighted[K] {
|
||||
all := make([]weighted[K], 0, len(m))
|
||||
for k, w := range m {
|
||||
if w >= eps || w <= -eps {
|
||||
all = append(all, weighted[K]{Key: k, Weight: w})
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].Weight != all[j].Weight {
|
||||
return all[i].Weight > all[j].Weight
|
||||
}
|
||||
return keyLess(all[i].Key, all[j].Key)
|
||||
})
|
||||
if topN+botN >= len(all) {
|
||||
return all
|
||||
}
|
||||
out := make([]weighted[K], 0, topN+botN)
|
||||
out = append(out, all[:topN]...)
|
||||
out = append(out, all[len(all)-botN:]...)
|
||||
return out
|
||||
}
|
||||
|
||||
// applyFloor clamps every weight in m up to floor (no entity goes more
|
||||
// negative than the floor).
|
||||
func applyFloor[K comparable](m map[K]float64, floor float64) {
|
||||
for k, w := range m {
|
||||
if w < floor {
|
||||
m[k] = floor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// uuidLess orders two UUIDs by raw bytes (deterministic tie-break).
|
||||
func uuidLess(a, b pgtype.UUID) bool {
|
||||
return bytes.Compare(a.Bytes[:], b.Bytes[:]) < 0
|
||||
}
|
||||
|
||||
// uuidString renders a pgtype.UUID canonically for log lines.
|
||||
func uuidString(u pgtype.UUID) string {
|
||||
if !u.Valid {
|
||||
return "<nil>"
|
||||
}
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x",
|
||||
u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
|
||||
}
|
||||
|
||||
// splitGenres splits a track's denormalized genre string on the common
|
||||
// multi-genre delimiters, trimming whitespace and dropping empties. Mirrors
|
||||
// the recommendation package's splitter (kept local to avoid a dependency).
|
||||
func splitGenres(genre *string) []string {
|
||||
if genre == nil || *genre == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.FieldsFunc(*genre, func(r rune) bool {
|
||||
return r == ';' || r == ','
|
||||
})
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package taste
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
)
|
||||
|
||||
var seedCounter uint64
|
||||
|
||||
func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
|
||||
|
||||
func newPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
if err := db.Migrate(dsn, discardLogger()); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
dbtest.ResetDB(t, pool)
|
||||
return pool
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
|
||||
t.Helper()
|
||||
u, err := dbq.New(pool).CreateUser(context.Background(), dbq.CreateUserParams{
|
||||
Username: dbtest.TestUserPrefix + name, PasswordHash: "x",
|
||||
ApiToken: name + "-token", IsAdmin: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func seedArtist(t *testing.T, pool *pgxpool.Pool, name string) dbq.Artist {
|
||||
t.Helper()
|
||||
a, err := dbq.New(pool).UpsertArtist(context.Background(), dbq.UpsertArtistParams{
|
||||
Name: name, SortName: name,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed artist: %v", err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// seedTrack creates an album + track under the given artist with a genre.
|
||||
func seedTrack(t *testing.T, pool *pgxpool.Pool, artistID pgtype.UUID, genre string) dbq.Track {
|
||||
t.Helper()
|
||||
q := dbq.New(pool)
|
||||
n := atomic.AddUint64(&seedCounter, 1)
|
||||
al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||
Title: fmt.Sprintf("Album %d", n), SortTitle: fmt.Sprintf("Album %d", n), ArtistID: artistID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed album: %v", err)
|
||||
}
|
||||
g := genre
|
||||
tk, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||
Title: fmt.Sprintf("Track %d", n), AlbumID: al.ID, ArtistID: artistID,
|
||||
DurationMs: 200000, FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("t%d.mp3", n)),
|
||||
FileSize: 100, FileFormat: "mp3", Genre: &g,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed track: %v", err)
|
||||
}
|
||||
return tk
|
||||
}
|
||||
|
||||
// seedPlay inserts a play with an explicit completion_ratio at startedAt.
|
||||
func seedPlay(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, completion float64) {
|
||||
t.Helper()
|
||||
_, err := pool.Exec(context.Background(), `
|
||||
WITH s AS (
|
||||
INSERT INTO play_sessions (user_id, started_at, last_event_at)
|
||||
VALUES ($1, $3, $3) RETURNING id
|
||||
)
|
||||
INSERT INTO play_events (user_id, track_id, session_id, started_at, completion_ratio, was_skipped)
|
||||
SELECT $1, $2, s.id, $3, $4, false FROM s
|
||||
`, userID, trackID, startedAt, completion)
|
||||
if err != nil {
|
||||
t.Fatalf("seed play: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func artistWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) (float64, bool) {
|
||||
t.Helper()
|
||||
var w float64
|
||||
err := pool.QueryRow(context.Background(),
|
||||
`SELECT weight FROM taste_profile_artists WHERE user_id = $1 AND artist_id = $2`,
|
||||
userID, artistID).Scan(&w)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("artist weight: %v", err)
|
||||
}
|
||||
return w, true
|
||||
}
|
||||
|
||||
func tagWeight(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string) (float64, bool) {
|
||||
t.Helper()
|
||||
var w float64
|
||||
err := pool.QueryRow(context.Background(),
|
||||
`SELECT weight FROM taste_profile_tags WHERE user_id = $1 AND tag = $2`,
|
||||
userID, tag).Scan(&w)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, false
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("tag weight: %v", err)
|
||||
}
|
||||
return w, true
|
||||
}
|
||||
|
||||
// TestBuildTasteProfile_PositiveVsNegative: a completed-track artist earns a
|
||||
// positive weight; an early-skipped artist earns a negative one. Genre tags
|
||||
// follow the same sign.
|
||||
func TestBuildTasteProfile_PositiveVsNegative(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
now := time.Now().UTC()
|
||||
u := seedUser(t, pool, "tp1")
|
||||
|
||||
loved := seedArtist(t, pool, "Loved")
|
||||
lovedTrack := seedTrack(t, pool, loved.ID, "Rock")
|
||||
skipped := seedArtist(t, pool, "Skipped")
|
||||
skippedTrack := seedTrack(t, pool, skipped.ID, "Noise")
|
||||
for i := 0; i < 3; i++ {
|
||||
seedPlay(t, pool, u.ID, lovedTrack.ID, now.Add(-time.Duration(i+1)*time.Hour), 1.0)
|
||||
seedPlay(t, pool, u.ID, skippedTrack.ID, now.Add(-time.Duration(i+1)*time.Hour), 0.02)
|
||||
}
|
||||
|
||||
if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
|
||||
lw, ok := artistWeight(t, pool, u.ID, loved.ID)
|
||||
if !ok || lw <= 0 {
|
||||
t.Errorf("loved artist weight = %.3f (present=%v), want > 0", lw, ok)
|
||||
}
|
||||
sw, ok := artistWeight(t, pool, u.ID, skipped.ID)
|
||||
if !ok || sw >= 0 {
|
||||
t.Errorf("skipped artist weight = %.3f (present=%v), want < 0", sw, ok)
|
||||
}
|
||||
if rw, ok := tagWeight(t, pool, u.ID, "Rock"); !ok || rw <= 0 {
|
||||
t.Errorf("Rock tag weight = %.3f (present=%v), want > 0", rw, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTasteProfile_AggregationProtectsArtist: one early-skip among many
|
||||
// completed plays must NOT make a loved artist net-negative (the user's
|
||||
// concern that a track-dislike shouldn't tank the artist).
|
||||
func TestBuildTasteProfile_AggregationProtectsArtist(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
now := time.Now().UTC()
|
||||
u := seedUser(t, pool, "tp2")
|
||||
fav := seedArtist(t, pool, "Fav")
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
tk := seedTrack(t, pool, fav.ID, "Indie")
|
||||
seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Duration(i+1)*time.Hour), 1.0)
|
||||
seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Duration(i+5)*time.Hour), 0.95)
|
||||
}
|
||||
// One early-skipped track by the same artist.
|
||||
skipTrack := seedTrack(t, pool, fav.ID, "Indie")
|
||||
seedPlay(t, pool, u.ID, skipTrack.ID, now.Add(-2*time.Hour), 0.02)
|
||||
|
||||
if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
w, ok := artistWeight(t, pool, u.ID, fav.ID)
|
||||
if !ok || w <= 0 {
|
||||
t.Errorf("fav artist weight = %.3f (present=%v), want > 0 despite one skip", w, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTasteProfile_ArtistLikeBonus: an explicitly-liked artist with no
|
||||
// plays still earns a positive weight from the like bonus.
|
||||
func TestBuildTasteProfile_ArtistLikeBonus(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
u := seedUser(t, pool, "tp3")
|
||||
liked := seedArtist(t, pool, "LikedOnly")
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`,
|
||||
u.ID, liked.ID); err != nil {
|
||||
t.Fatalf("seed artist like: %v", err)
|
||||
}
|
||||
|
||||
if err := BuildTasteProfile(context.Background(), pool, discardLogger(), u.ID, DefaultConfig()); err != nil {
|
||||
t.Fatalf("build: %v", err)
|
||||
}
|
||||
w, ok := artistWeight(t, pool, u.ID, liked.ID)
|
||||
if !ok || w <= 0 {
|
||||
t.Errorf("liked-only artist weight = %.3f (present=%v), want > 0 from like bonus", w, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildTasteProfile_AtomicReplace: a second build replaces, not appends.
|
||||
func TestBuildTasteProfile_AtomicReplace(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
u := seedUser(t, pool, "tp4")
|
||||
a := seedArtist(t, pool, "A")
|
||||
tk := seedTrack(t, pool, a.ID, "Rock")
|
||||
seedPlay(t, pool, u.ID, tk.ID, now.Add(-time.Hour), 1.0)
|
||||
|
||||
count := func() int {
|
||||
var n int
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM taste_profile_artists WHERE user_id = $1`, u.ID).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := BuildTasteProfile(ctx, pool, discardLogger(), u.ID, DefaultConfig()); err != nil {
|
||||
t.Fatalf("build %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := count(); got != 1 {
|
||||
t.Errorf("artist rows after two builds = %d, want 1 (atomic replace)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package taste
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func strLess(a, b string) bool { return a < b }
|
||||
|
||||
func TestRankWeighted_DropsEpsilonAndSortsDesc(t *testing.T) {
|
||||
m := map[string]float64{"a": 3.0, "b": 1.0, "tiny": 0.01, "d": -2.0}
|
||||
got := rankWeighted(m, 0.05, 10, 10, strLess)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len = %d, want 3 (tiny dropped by epsilon)", len(got))
|
||||
}
|
||||
wantOrder := []string{"a", "b", "d"} // 3, 1, -2 descending
|
||||
for i, w := range wantOrder {
|
||||
if got[i].Key != w {
|
||||
t.Errorf("pos %d = %q, want %q", i, got[i].Key, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankWeighted_CapsTopAndBottom(t *testing.T) {
|
||||
m := map[string]float64{"e5": 5, "e4": 4, "e3": 3, "e2": 2, "e1": 1}
|
||||
got := rankWeighted(m, 0.0, 1, 1, strLess) // top 1 + bottom 1
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Key != "e5" || got[1].Key != "e1" {
|
||||
t.Errorf("got [%s,%s], want [e5,e1]", got[0].Key, got[1].Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankWeighted_DeterministicTieBreak(t *testing.T) {
|
||||
m := map[string]float64{"z": 1.0, "a": 1.0, "m": 1.0}
|
||||
got := rankWeighted(m, 0.0, 10, 10, strLess)
|
||||
// Equal weights → ordered by keyLess (string asc): a, m, z.
|
||||
want := []string{"a", "m", "z"}
|
||||
for i, w := range want {
|
||||
if got[i].Key != w {
|
||||
t.Errorf("tie-break pos %d = %q, want %q", i, got[i].Key, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyFloor_ClampsNegatives(t *testing.T) {
|
||||
m := map[string]float64{"a": -5, "b": 2, "c": -1}
|
||||
applyFloor(m, -3)
|
||||
if m["a"] != -3 {
|
||||
t.Errorf("a = %.1f, want -3 (floored)", m["a"])
|
||||
}
|
||||
if m["b"] != 2 {
|
||||
t.Errorf("b = %.1f, want 2 (untouched)", m["b"])
|
||||
}
|
||||
if m["c"] != -1 {
|
||||
t.Errorf("c = %.1f, want -1 (above floor)", m["c"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitGenres(t *testing.T) {
|
||||
mk := func(s string) *string { return &s }
|
||||
cases := []struct {
|
||||
in *string
|
||||
want []string
|
||||
}{
|
||||
{nil, nil},
|
||||
{mk(""), nil},
|
||||
{mk("Rock"), []string{"Rock"}},
|
||||
{mk(" Rock "), []string{"Rock"}},
|
||||
{mk("Rock; Pop, Jazz"), []string{"Rock", "Pop", "Jazz"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := splitGenres(c.in)
|
||||
if len(got) != len(c.want) {
|
||||
t.Errorf("splitGenres(%v) len = %d, want %d", c.in, len(got), len(c.want))
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != c.want[i] {
|
||||
t.Errorf("splitGenres(%v)[%d] = %q, want %q", c.in, i, got[i], c.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUuidLess_Orders(t *testing.T) {
|
||||
a := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||
b := pgtype.UUID{Bytes: [16]byte{2}, Valid: true}
|
||||
if !uuidLess(a, b) || uuidLess(b, a) {
|
||||
t.Error("uuidLess byte ordering wrong")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":semanticCommits"
|
||||
],
|
||||
"baseBranches": ["dev"],
|
||||
"timezone": "America/New_York",
|
||||
"schedule": ["every weekend"],
|
||||
"prHourlyLimit": 2,
|
||||
"prConcurrentLimit": 8,
|
||||
"ignorePaths": [
|
||||
"**/node_modules/**",
|
||||
"**/vendor/**",
|
||||
"flutter_client/**"
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": ["before 5am on the first day of the month"]
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Auto-merge patch/minor/digest/pin bumps once CI is green",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Hold all major bumps for manual approval via the dependency dashboard",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"automerge": false,
|
||||
"dependencyDashboardApproval": true,
|
||||
"addLabels": ["deps", "deps:major"]
|
||||
},
|
||||
{
|
||||
"description": "Group Go module updates into one PR",
|
||||
"matchManagers": ["gomod"],
|
||||
"groupName": "go modules"
|
||||
},
|
||||
{
|
||||
"description": "Group CI workflow action bumps",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "ci actions"
|
||||
},
|
||||
{
|
||||
"description": "Group Docker base-image bumps (Dockerfile + compose)",
|
||||
"matchManagers": ["dockerfile", "docker-compose"],
|
||||
"groupName": "docker images"
|
||||
},
|
||||
{
|
||||
"description": "Group the Android Gradle/Kotlin toolchain",
|
||||
"matchManagers": ["gradle", "gradle-wrapper"],
|
||||
"groupName": "android gradle"
|
||||
},
|
||||
{
|
||||
"description": "Group web npm non-major bumps",
|
||||
"matchManagers": ["npm"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "web npm (non-major)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getScanSchedule, updateScanSchedule, type ScanSchedule } from './admin';
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
|
||||
}));
|
||||
|
||||
import { api } from './client';
|
||||
|
||||
describe('admin scan-schedule API', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('getScanSchedule GETs the correct path', async () => {
|
||||
const sample: ScanSchedule = {
|
||||
mode: 'daily',
|
||||
interval_hours: null,
|
||||
time_of_day: '03:00',
|
||||
weekly_day: null,
|
||||
next_scheduled_at: '2026-05-07T03:00:00Z'
|
||||
};
|
||||
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||
const got = await getScanSchedule();
|
||||
expect(api.get).toHaveBeenCalledWith('/api/admin/scan/schedule');
|
||||
expect(got).toEqual(sample);
|
||||
});
|
||||
|
||||
it('updateScanSchedule PATCHes with the right body', async () => {
|
||||
const sample: ScanSchedule = {
|
||||
mode: 'interval',
|
||||
interval_hours: 6,
|
||||
time_of_day: null,
|
||||
weekly_day: null,
|
||||
next_scheduled_at: '2026-05-06T20:00:00Z'
|
||||
};
|
||||
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||
const got = await updateScanSchedule({ mode: 'interval', interval_hours: 6 });
|
||||
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', {
|
||||
mode: 'interval',
|
||||
interval_hours: 6
|
||||
});
|
||||
expect(got.mode).toBe('interval');
|
||||
expect(got.interval_hours).toBe(6);
|
||||
});
|
||||
|
||||
it('updateScanSchedule with mode=off sends just mode', async () => {
|
||||
const sample: ScanSchedule = {
|
||||
mode: 'off',
|
||||
interval_hours: null,
|
||||
time_of_day: null,
|
||||
weekly_day: null,
|
||||
next_scheduled_at: null
|
||||
};
|
||||
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||
const got = await updateScanSchedule({ mode: 'off' });
|
||||
expect(api.patch).toHaveBeenCalledWith('/api/admin/scan/schedule', { mode: 'off' });
|
||||
expect(got.next_scheduled_at).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -383,43 +383,6 @@ export function createCoverProvidersQuery() {
|
||||
});
|
||||
}
|
||||
|
||||
// Scan schedule -----------------------------------------------------------
|
||||
|
||||
export type ScanScheduleMode = 'off' | 'interval' | 'daily' | 'weekly';
|
||||
|
||||
export type ScanSchedule = {
|
||||
mode: ScanScheduleMode;
|
||||
interval_hours: number | null;
|
||||
time_of_day: string | null; // 'HH:MM'
|
||||
weekly_day: number | null; // 1..7 (Mon..Sun, ISO 8601)
|
||||
next_scheduled_at: string | null; // RFC3339 ISO timestamp; null when mode='off'
|
||||
};
|
||||
|
||||
export type ScanScheduleUpdate = {
|
||||
mode: ScanScheduleMode;
|
||||
interval_hours?: number | null;
|
||||
time_of_day?: string | null;
|
||||
weekly_day?: number | null;
|
||||
};
|
||||
|
||||
export async function getScanSchedule(): Promise<ScanSchedule> {
|
||||
return api.get<ScanSchedule>('/api/admin/scan/schedule');
|
||||
}
|
||||
|
||||
export async function updateScanSchedule(patch: ScanScheduleUpdate): Promise<ScanSchedule> {
|
||||
return api.patch<ScanSchedule>('/api/admin/scan/schedule', patch);
|
||||
}
|
||||
|
||||
// Settings rarely change in operator time. 60s staleTime; refetches
|
||||
// on window focus + after any PATCH invalidation — no polling.
|
||||
export function createScanScheduleQuery() {
|
||||
return createQuery({
|
||||
queryKey: qk.scanSchedule(),
|
||||
queryFn: getScanSchedule,
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
// Admin user-management ------------------------------------------------------
|
||||
|
||||
export type AdminUser = {
|
||||
|
||||
@@ -18,7 +18,9 @@ describe('home client', () => {
|
||||
rediscover_albums: [],
|
||||
rediscover_artists: [],
|
||||
most_played_tracks: [],
|
||||
last_played_artists: []
|
||||
last_played_artists: [],
|
||||
you_might_like_albums: [],
|
||||
you_might_like_artists: []
|
||||
};
|
||||
(api.get as ReturnType<typeof vi.fn>).mockResolvedValueOnce(fixture);
|
||||
const got = await listHome();
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import { api } from './client';
|
||||
|
||||
// Mirrors internal/api/me_recommendation_metrics.go.
|
||||
export type RecommendationMetric = {
|
||||
source: string;
|
||||
plays: number;
|
||||
skips: number;
|
||||
skip_rate: number;
|
||||
avg_completion: number;
|
||||
};
|
||||
|
||||
export type RecommendationMetrics = {
|
||||
window_days: number;
|
||||
sources: RecommendationMetric[];
|
||||
};
|
||||
|
||||
export function getRecommendationMetrics(): Promise<RecommendationMetrics> {
|
||||
return api.get<RecommendationMetrics>('/api/me/recommendation-metrics');
|
||||
}
|
||||
|
||||
export const REC_METRICS_QUERY_KEY = ['settings', 'recommendation-metrics'] as const;
|
||||
|
||||
export function createRecommendationMetricsQuery() {
|
||||
return createQuery({
|
||||
queryKey: REC_METRICS_QUERY_KEY,
|
||||
queryFn: getRecommendationMetrics,
|
||||
staleTime: 60_000
|
||||
});
|
||||
}
|
||||
|
||||
// Friendly labels for the system-playlist source keys (play_events.source).
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
for_you: 'For You',
|
||||
discover: 'Discover',
|
||||
deep_cuts: 'Deep cuts',
|
||||
rediscover: 'Rediscover',
|
||||
new_for_you: 'New for you',
|
||||
on_this_day: 'On this day',
|
||||
first_listens: 'First listens',
|
||||
songs_like_artist: 'Songs like…'
|
||||
};
|
||||
|
||||
export function sourceLabel(source: string): string {
|
||||
return SOURCE_LABELS[source] ?? source;
|
||||
}
|
||||
@@ -46,7 +46,6 @@ export const qk = {
|
||||
adminPlaybackErrors: (resolved?: boolean) =>
|
||||
['adminPlaybackErrors', { resolved: resolved ?? false }] as const,
|
||||
scanStatus: () => ['scanStatus'] as const,
|
||||
scanSchedule: () => ['scanSchedule'] as const,
|
||||
coverage: () => ['coverage'] as const,
|
||||
coverProviders: () => ['coverProviders'] as const,
|
||||
adminUsers: () => ['adminUsers'] as const,
|
||||
@@ -57,6 +56,8 @@ export const qk = {
|
||||
home: () => ['home'] as const,
|
||||
albumsAlpha: () => ['albumsAlpha'] as const,
|
||||
artistTracks: (artistId: string) => ['artistTracks', artistId] as const,
|
||||
similarArtists: (artistId: string) => ['similarArtists', artistId] as const,
|
||||
artistTopTracks: (artistId: string) => ['artistTopTracks', artistId] as const,
|
||||
playlists: (kind?: 'user' | 'system' | 'all') =>
|
||||
['playlists', { kind: kind ?? 'user' }] as const,
|
||||
playlist: (id: string) => ['playlist', id] as const,
|
||||
@@ -89,6 +90,22 @@ export function createAlbumQuery(id: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function createSimilarArtistsQuery(id: string) {
|
||||
return createQuery({
|
||||
queryKey: qk.similarArtists(id),
|
||||
queryFn: () => api.get<ArtistRef[]>(`/api/artists/${id}/similar`),
|
||||
enabled: id.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function createArtistTopTracksQuery(id: string) {
|
||||
return createQuery({
|
||||
queryKey: qk.artistTopTracks(id),
|
||||
queryFn: () => api.get<TrackRef[]>(`/api/artists/${id}/top-tracks`),
|
||||
enabled: id.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSearchQuery(q: string) {
|
||||
return createQuery({
|
||||
queryKey: qk.search(q),
|
||||
|
||||
@@ -342,4 +342,6 @@ export type HomePayload = {
|
||||
rediscover_artists: ArtistRef[];
|
||||
most_played_tracks: TrackRef[];
|
||||
last_played_artists: ArtistRef[];
|
||||
you_might_like_albums: AlbumRef[];
|
||||
you_might_like_artists: ArtistRef[];
|
||||
};
|
||||
|
||||
@@ -161,6 +161,42 @@
|
||||
{:else if showSkeleton.value && !data}
|
||||
<p class="text-text-secondary">Loading…</p>
|
||||
{:else if data}
|
||||
<!-- You might like: taste-predicted in-library albums + artists the user
|
||||
doesn't actively spin (server section, daily-built, cold-start gated).
|
||||
Sits directly under the system playlists row. Two scrollers like
|
||||
Rediscover (square album vs circular artist cards). -->
|
||||
<section class="space-y-3">
|
||||
{#if data.you_might_like_albums.length === 0 && data.you_might_like_artists.length === 0}
|
||||
<header>
|
||||
<h2 class="font-display text-2xl font-medium text-text-primary">You might like</h2>
|
||||
</header>
|
||||
<p class="text-text-secondary">We're still learning your taste — keep listening and picks you might like will show up here.</p>
|
||||
{:else}
|
||||
{#if data.you_might_like_albums.length > 0}
|
||||
<HorizontalScrollRow
|
||||
rows={[data.you_might_like_albums]}
|
||||
title="You might like"
|
||||
ariaLabel="You might like albums"
|
||||
>
|
||||
{#snippet item(album: import('$lib/api/types').AlbumRef)}
|
||||
<div class="w-40"><AlbumCard {album} /></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
{#if data.you_might_like_artists.length > 0}
|
||||
<HorizontalScrollRow
|
||||
rows={[data.you_might_like_artists]}
|
||||
title={data.you_might_like_albums.length === 0 ? 'You might like' : undefined}
|
||||
ariaLabel="You might like artists"
|
||||
>
|
||||
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
|
||||
<div class="w-36"><ArtistCard {artist} /></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Recently added: 50 albums in 2 rows of 25, scrolling together -->
|
||||
<section class="space-y-3">
|
||||
{#if data.recently_added_albums.length === 0}
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
createAdminQuarantineQuery,
|
||||
createScanStatusQuery,
|
||||
createCoverageQuery,
|
||||
createScanScheduleQuery,
|
||||
updateScanSchedule,
|
||||
approveRequest,
|
||||
rejectRequest,
|
||||
resolveQuarantine,
|
||||
@@ -19,9 +17,7 @@
|
||||
deleteQuarantineViaLidarr,
|
||||
refetchMissingCovers,
|
||||
triggerScan,
|
||||
researchMissingArt,
|
||||
type ScanSchedule,
|
||||
type ScanScheduleMode
|
||||
researchMissingArt
|
||||
} from '$lib/api/admin';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { errCode } from '$lib/api/errors';
|
||||
@@ -211,89 +207,6 @@
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
// ---- Scan schedule ----
|
||||
const scheduleStore = $derived(createScanScheduleQuery());
|
||||
const scheduleQ = $derived($scheduleStore);
|
||||
const schedule = $derived(scheduleQ.data);
|
||||
|
||||
let scheduleLocal = $state<{
|
||||
mode: ScanScheduleMode;
|
||||
interval_hours: number;
|
||||
time_of_day: string;
|
||||
weekly_day: number;
|
||||
}>({
|
||||
mode: 'off',
|
||||
interval_hours: 6,
|
||||
time_of_day: '03:00',
|
||||
weekly_day: 7,
|
||||
});
|
||||
let scheduleSaving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (schedule) {
|
||||
scheduleLocal = {
|
||||
mode: schedule.mode,
|
||||
interval_hours: schedule.interval_hours ?? 6,
|
||||
time_of_day: schedule.time_of_day ?? '03:00',
|
||||
weekly_day: schedule.weekly_day ?? 7,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
function scheduleHasChanges(): boolean {
|
||||
if (!schedule) return false;
|
||||
if (scheduleLocal.mode !== schedule.mode) return true;
|
||||
switch (scheduleLocal.mode) {
|
||||
case 'interval':
|
||||
return scheduleLocal.interval_hours !== (schedule.interval_hours ?? -1);
|
||||
case 'daily':
|
||||
return scheduleLocal.time_of_day !== (schedule.time_of_day ?? '');
|
||||
case 'weekly':
|
||||
return scheduleLocal.time_of_day !== (schedule.time_of_day ?? '')
|
||||
|| scheduleLocal.weekly_day !== (schedule.weekly_day ?? -1);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveSchedule() {
|
||||
scheduleSaving = true;
|
||||
try {
|
||||
const patch: { mode: ScanScheduleMode; interval_hours?: number; time_of_day?: string; weekly_day?: number } = {
|
||||
mode: scheduleLocal.mode,
|
||||
};
|
||||
if (scheduleLocal.mode === 'interval') patch.interval_hours = scheduleLocal.interval_hours;
|
||||
if (scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly') patch.time_of_day = scheduleLocal.time_of_day;
|
||||
if (scheduleLocal.mode === 'weekly') patch.weekly_day = scheduleLocal.weekly_day;
|
||||
await updateScanSchedule(patch);
|
||||
await client.invalidateQueries({ queryKey: qk.scanSchedule() });
|
||||
} catch (e) {
|
||||
pushToast(`Schedule save failed: ${errCode(e)}`, 'error');
|
||||
} finally {
|
||||
scheduleSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const target = new Date(iso);
|
||||
const now = new Date();
|
||||
const diffMs = target.getTime() - now.getTime();
|
||||
const absHours = Math.abs(diffMs) / 3_600_000;
|
||||
let rel: string;
|
||||
if (absHours < 1) {
|
||||
const mins = Math.round(Math.abs(diffMs) / 60_000);
|
||||
rel = diffMs >= 0 ? `in ${mins} min` : `${mins} min ago`;
|
||||
} else if (absHours < 48) {
|
||||
const h = Math.round(absHours);
|
||||
rel = diffMs >= 0 ? `in ${h} hour${h === 1 ? '' : 's'}` : `${h} hour${h === 1 ? '' : 's'} ago`;
|
||||
} else {
|
||||
const days = Math.round(absHours / 24);
|
||||
rel = diffMs >= 0 ? `in ${days} days` : `${days} days ago`;
|
||||
}
|
||||
const local = target.toLocaleString();
|
||||
return `${rel} (${local})`;
|
||||
}
|
||||
|
||||
// ---- Cover art bulk refetch ----
|
||||
let bulkBusy = $state(false);
|
||||
let bulkResult = $state<string | null>(null);
|
||||
@@ -517,77 +430,6 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Scan schedule -->
|
||||
<section class="rounded border border-border bg-surface p-4 space-y-3">
|
||||
<div>
|
||||
<h2 class="font-display text-lg font-medium">Scan schedule</h2>
|
||||
<p class="mt-1 text-sm text-text-secondary">
|
||||
Run a library scan automatically on a recurring cadence. Manual triggers
|
||||
and boot scans are unaffected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if schedule}
|
||||
<div class="grid gap-3 sm:grid-cols-[auto_1fr] items-center">
|
||||
<label class="text-sm text-text-secondary" for="schedule-mode">Mode</label>
|
||||
<select id="schedule-mode" bind:value={scheduleLocal.mode}
|
||||
class="text-sm rounded border border-border bg-background px-2 py-1">
|
||||
<option value="off">Off</option>
|
||||
<option value="interval">Every N hours</option>
|
||||
<option value="daily">Daily at time</option>
|
||||
<option value="weekly">Weekly at day + time</option>
|
||||
</select>
|
||||
|
||||
{#if scheduleLocal.mode === 'interval'}
|
||||
<label class="text-sm text-text-secondary" for="schedule-interval">Interval</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input id="schedule-interval" type="number" min="1" max="168"
|
||||
bind:value={scheduleLocal.interval_hours}
|
||||
class="w-20 text-sm rounded border border-border bg-background px-2 py-1" />
|
||||
<span class="text-sm text-text-muted">hours</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if scheduleLocal.mode === 'daily' || scheduleLocal.mode === 'weekly'}
|
||||
<label class="text-sm text-text-secondary" for="schedule-time">Time</label>
|
||||
<input id="schedule-time" type="time" bind:value={scheduleLocal.time_of_day}
|
||||
class="text-sm rounded border border-border bg-background px-2 py-1" />
|
||||
{/if}
|
||||
|
||||
{#if scheduleLocal.mode === 'weekly'}
|
||||
<label class="text-sm text-text-secondary" for="schedule-day">Day</label>
|
||||
<select id="schedule-day" bind:value={scheduleLocal.weekly_day}
|
||||
class="text-sm rounded border border-border bg-background px-2 py-1">
|
||||
<option value={1}>Monday</option>
|
||||
<option value={2}>Tuesday</option>
|
||||
<option value={3}>Wednesday</option>
|
||||
<option value={4}>Thursday</option>
|
||||
<option value={5}>Friday</option>
|
||||
<option value={6}>Saturday</option>
|
||||
<option value={7}>Sunday</option>
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="button" disabled={!scheduleHasChanges() || scheduleSaving}
|
||||
onclick={onSaveSchedule}
|
||||
class="rounded-md bg-action-primary px-4 py-2 text-sm font-medium text-action-fg hover:opacity-90 disabled:opacity-50">
|
||||
{scheduleSaving ? 'Saving…' : 'Save changes'}
|
||||
</button>
|
||||
{#if schedule.next_scheduled_at}
|
||||
<span class="text-xs text-text-muted">
|
||||
Next scan: {formatRelative(schedule.next_scheduled_at)}
|
||||
</span>
|
||||
{:else if scheduleLocal.mode === 'off'}
|
||||
<span class="text-xs text-text-muted">Schedule is off.</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-text-muted">Loading…</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Cover art bulk refetch -->
|
||||
<section class="rounded border border-border bg-surface p-4">
|
||||
<h2 class="font-display text-lg font-medium">Cover art</h2>
|
||||
|
||||
@@ -42,21 +42,7 @@ vi.mock('$lib/api/admin', async () => {
|
||||
deleteQuarantineViaLidarr: vi.fn().mockResolvedValue({}),
|
||||
triggerScan: vi.fn().mockResolvedValue({}),
|
||||
refetchMissingCovers: vi.fn().mockResolvedValue({ started: true }),
|
||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 }),
|
||||
createScanScheduleQuery: vi.fn(() =>
|
||||
readable({
|
||||
data: {
|
||||
mode: 'off',
|
||||
interval_hours: null,
|
||||
time_of_day: null,
|
||||
weekly_day: null,
|
||||
next_scheduled_at: null,
|
||||
},
|
||||
isPending: false,
|
||||
isError: false
|
||||
})
|
||||
),
|
||||
updateScanSchedule: vi.fn().mockResolvedValue({})
|
||||
researchMissingArt: vi.fn().mockResolvedValue({ version: 1 })
|
||||
};
|
||||
});
|
||||
|
||||
@@ -250,14 +236,6 @@ describe('admin Overview page', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('renders the scan-schedule section with mode=off default', () => {
|
||||
setupOverview();
|
||||
render(OverviewPage);
|
||||
expect(screen.getByText('Scan schedule')).toBeInTheDocument();
|
||||
const select = screen.getByLabelText('Mode') as HTMLSelectElement;
|
||||
expect(select.value).toBe('off');
|
||||
});
|
||||
|
||||
test('renders Re-search missing art button and invokes on click', async () => {
|
||||
setupOverview();
|
||||
render(OverviewPage);
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Play } from 'lucide-svelte';
|
||||
import { page } from '$app/state';
|
||||
import { createArtistQuery } from '$lib/api/queries';
|
||||
import {
|
||||
createArtistQuery,
|
||||
createSimilarArtistsQuery,
|
||||
createArtistTopTracksQuery
|
||||
} from '$lib/api/queries';
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import AlbumCard from '$lib/components/AlbumCard.svelte';
|
||||
import ArtistCard from '$lib/components/ArtistCard.svelte';
|
||||
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
|
||||
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
|
||||
import LikeButton from '$lib/components/LikeButton.svelte';
|
||||
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
|
||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||
@@ -11,13 +18,20 @@
|
||||
import { api } from '$lib/api/client';
|
||||
import { playQueue } from '$lib/player/store.svelte';
|
||||
import type { ApiError } from '$lib/api/client';
|
||||
import type { TrackRef } from '$lib/api/types';
|
||||
import type { ArtistRef, TrackRef } from '$lib/api/types';
|
||||
|
||||
const id = $derived(page.params.id ?? '');
|
||||
const queryStore = $derived(createArtistQuery(id));
|
||||
const query = $derived($queryStore);
|
||||
const showSkeleton = useDelayed(() => query.isPending);
|
||||
|
||||
// Secondary sections, lazy-loaded alongside the detail. Each hides itself
|
||||
// when empty (no similarity matches yet / user hasn't played this artist).
|
||||
const similarStore = $derived(createSimilarArtistsQuery(id));
|
||||
const similarQuery = $derived($similarStore);
|
||||
const topTracksStore = $derived(createArtistTopTracksQuery(id));
|
||||
const topTracksQuery = $derived($topTracksStore);
|
||||
|
||||
const notFound = $derived(
|
||||
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
|
||||
);
|
||||
@@ -97,5 +111,30 @@
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (topTracksQuery.data?.length ?? 0) > 0}
|
||||
{@const top = topTracksQuery.data ?? []}
|
||||
<section class="space-y-3">
|
||||
<HorizontalScrollRow rows={[top]} title="Top tracks" ariaLabel={`Top tracks for ${detail.name}`}>
|
||||
{#snippet item(track: TrackRef, globalIdx: number)}
|
||||
<CompactTrackCard {track} sectionTracks={top} index={globalIdx} />
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if (similarQuery.data?.length ?? 0) > 0}
|
||||
<section class="space-y-3">
|
||||
<HorizontalScrollRow
|
||||
rows={[similarQuery.data ?? []]}
|
||||
title="Similar artists"
|
||||
ariaLabel="Similar artists"
|
||||
>
|
||||
{#snippet item(artist: ArtistRef)}
|
||||
<div class="w-36"><ArtistCard {artist} /></div>
|
||||
{/snippet}
|
||||
</HorizontalScrollRow>
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</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 } from '@testing-library/svelte';
|
||||
import { flushSync } from 'svelte';
|
||||
import { mockQuery } from '../../../test-utils/query';
|
||||
@@ -16,13 +16,19 @@ vi.mock('$app/state', () => ({
|
||||
|
||||
vi.mock('$lib/api/queries', () => ({
|
||||
qk: { artist: (id: string) => ['artist', id] },
|
||||
createArtistQuery: vi.fn()
|
||||
createArtistQuery: vi.fn(),
|
||||
createSimilarArtistsQuery: vi.fn(),
|
||||
createArtistTopTracksQuery: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/likes', () => emptyLikesMock());
|
||||
|
||||
import ArtistPage from './+page.svelte';
|
||||
import { createArtistQuery } from '$lib/api/queries';
|
||||
import {
|
||||
createArtistQuery,
|
||||
createSimilarArtistsQuery,
|
||||
createArtistTopTracksQuery
|
||||
} from '$lib/api/queries';
|
||||
|
||||
function album(id: string, title: string, year?: number): AlbumRef {
|
||||
return {
|
||||
@@ -40,6 +46,13 @@ afterEach(() => {
|
||||
state.pageParams = { id: 'abc' };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Secondary sections default to empty so the existing album-grid assertions
|
||||
// are unaffected; individual tests can override to exercise the new strips.
|
||||
(createSimilarArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
|
||||
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: [] }));
|
||||
});
|
||||
|
||||
describe('artist detail page', () => {
|
||||
test('renders artist name, subtitle, and one AlbumCard per album', () => {
|
||||
const detail: ArtistDetail = {
|
||||
@@ -54,6 +67,28 @@ describe('artist detail page', () => {
|
||||
expect(screen.getByRole('link', { name: /Second/ })).toHaveAttribute('href', '/albums/a2');
|
||||
});
|
||||
|
||||
test('renders top-tracks panel and similar-artists strip when present', () => {
|
||||
const detail: ArtistDetail = {
|
||||
id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 1, cover_url: '',
|
||||
albums: [album('a1', 'First', 2020)]
|
||||
};
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({ data: detail }));
|
||||
(createArtistTopTracksQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||
data: [{
|
||||
id: 't1', title: 'Hit Song', album_id: 'a1', album_title: 'First',
|
||||
artist_id: 'abc', artist_name: 'Alice', duration_sec: 200, stream_url: '/s/t1'
|
||||
}]
|
||||
}));
|
||||
(createSimilarArtistsQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||
data: [{ id: 'sim1', name: 'Bob', sort_name: 'Bob', album_count: 2, cover_url: '' }]
|
||||
}));
|
||||
render(ArtistPage);
|
||||
expect(screen.getByText(/top tracks/i)).toBeInTheDocument();
|
||||
expect(screen.getByText('Hit Song')).toBeInTheDocument();
|
||||
expect(screen.getByText(/similar artists/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/sim1');
|
||||
});
|
||||
|
||||
test('back link points to Library', () => {
|
||||
(createArtistQuery as ReturnType<typeof vi.fn>).mockReturnValue(mockQuery({
|
||||
data: { id: 'abc', name: 'Alice', sort_name: 'Alice', album_count: 0, cover_url: '', albums: [] }
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { pageTitle } from '$lib/branding';
|
||||
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
||||
import { createRequest } from '$lib/api/requests';
|
||||
import { qk } from '$lib/api/queries';
|
||||
import { useQueryClient } from '@tanstack/svelte-query';
|
||||
import DiscoverResultCard from '$lib/components/DiscoverResultCard.svelte';
|
||||
import DiscoverTabs from '$lib/components/DiscoverTabs.svelte';
|
||||
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
|
||||
@@ -12,6 +14,8 @@
|
||||
LidarrSearchResult
|
||||
} from '$lib/api/types';
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Local input is the source of truth for the discover query — Shell's global
|
||||
// SearchInput targets /search, not /discover. We debounce by 250ms before
|
||||
// pushing into `debouncedQ`, which is what the query factory observes.
|
||||
@@ -87,6 +91,8 @@
|
||||
const next = new Set(optimisticRequested);
|
||||
next.add(r.mbid);
|
||||
optimisticRequested = next;
|
||||
// Surface the new request on /requests right away (+ start its poll) (#369).
|
||||
queryClient.invalidateQueries({ queryKey: qk.myRequests() });
|
||||
} catch {
|
||||
// Swallow for v1: error toasts land in a later UX pass. The card stays
|
||||
// in 'requestable' so the user can retry.
|
||||
|
||||
@@ -31,6 +31,15 @@ vi.mock('$lib/api/requests', () => ({
|
||||
|
||||
vi.mock('$lib/api/client', () => apiClientMock());
|
||||
|
||||
// The page calls useQueryClient() to invalidate qk.myRequests() after a
|
||||
// submit (#369); stub it so rendering doesn't require a real QueryClient and
|
||||
// we can assert the invalidation fires.
|
||||
const queryClientMock = vi.hoisted(() => ({ invalidateQueries: vi.fn() }));
|
||||
vi.mock('@tanstack/svelte-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/svelte-query')>();
|
||||
return { ...actual, useQueryClient: () => queryClientMock };
|
||||
});
|
||||
|
||||
import DiscoverPage from './+page.svelte';
|
||||
import { createLidarrSearchQuery } from '$lib/api/lidarr';
|
||||
import { createSuggestionsQuery } from '$lib/api/suggestions';
|
||||
@@ -187,6 +196,27 @@ describe('Discover page', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('submitting a request invalidates myRequests so /requests updates (#369)', async () => {
|
||||
vi.useFakeTimers();
|
||||
const r = result({ mbid: 'art-mbid', artist_mbid: 'art-mbid', name: 'Boards of Canada' });
|
||||
mockedCreateQuery.mockReturnValue(mockQuery<LidarrSearchResult[]>({ data: [r] }));
|
||||
render(DiscoverPage);
|
||||
const input = screen.getByLabelText(/search lidarr/i);
|
||||
await fireEvent.input(input, { target: { value: 'boards' } });
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
vi.useRealTimers();
|
||||
|
||||
const requestBtn = await screen.findByRole('button', {
|
||||
name: /request boards of canada/i
|
||||
});
|
||||
await fireEvent.click(requestBtn);
|
||||
await waitFor(() => {
|
||||
expect(queryClientMock.invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['myRequests']
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('track-kind Request click opens confirm modal; Confirm calls createRequest', async () => {
|
||||
vi.useFakeTimers();
|
||||
const r = result({
|
||||
|
||||
@@ -13,7 +13,9 @@ vi.mock('$lib/api/home', () => ({
|
||||
most_played_tracks: [],
|
||||
rediscover_albums: [],
|
||||
rediscover_artists: [],
|
||||
last_played_artists: []
|
||||
last_played_artists: [],
|
||||
you_might_like_albums: [],
|
||||
you_might_like_artists: []
|
||||
},
|
||||
isPending: false,
|
||||
isError: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user