Compare commits
25
Commits
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
import com.fabledsword.minstrel.admin.data.AdminInvitesRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.Invite
|
import com.fabledsword.minstrel.models.Invite
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
@@ -25,6 +27,7 @@ data class AdminInvitesUiState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminInvitesViewModel @Inject constructor(
|
class AdminInvitesViewModel @Inject constructor(
|
||||||
private val repository: AdminInvitesRepository,
|
private val repository: AdminInvitesRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(AdminInvitesUiState())
|
private val internal = MutableStateFlow(AdminInvitesUiState())
|
||||||
@@ -36,6 +39,13 @@ class AdminInvitesViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value.message != null) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
|
|||||||
+4
-2
@@ -29,6 +29,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -62,9 +63,10 @@ fun AdminQuarantineScreen(
|
|||||||
body = "When users flag tracks as bad rips, wrong tags, or " +
|
body = "When users flag tracks as bad rips, wrong tags, or " +
|
||||||
"duplicates, their reports get aggregated and surfaced here.",
|
"duplicates, their reports get aggregated and surfaced here.",
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Error -> EmptyState(
|
is AdminQuarantineUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load queue",
|
title = "Couldn't load queue",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminQuarantineUiState.Success -> QueueList(
|
is AdminQuarantineUiState.Success -> QueueList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
+10
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
import com.fabledsword.minstrel.models.AdminQuarantineItemRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -26,6 +28,7 @@ sealed interface AdminQuarantineUiState {
|
|||||||
class AdminQuarantineViewModel @Inject constructor(
|
class AdminQuarantineViewModel @Inject constructor(
|
||||||
private val repository: AdminQuarantineRepository,
|
private val repository: AdminQuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
private val internal = MutableStateFlow<AdminQuarantineUiState>(AdminQuarantineUiState.Loading)
|
||||||
@@ -38,6 +41,13 @@ class AdminQuarantineViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminQuarantineUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import androidx.navigation.NavHostController
|
|||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -61,9 +62,10 @@ fun AdminRequestsScreen(
|
|||||||
body = "When users ask Lidarr for new music, their pending " +
|
body = "When users ask Lidarr for new music, their pending " +
|
||||||
"requests show up here for approval.",
|
"requests show up here for approval.",
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Error -> EmptyState(
|
is AdminRequestsUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AdminRequestsUiState.Success -> RequestList(
|
is AdminRequestsUiState.Success -> RequestList(
|
||||||
rows = s.rows,
|
rows = s.rows,
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -35,6 +37,7 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
private val repository: AdminRequestsRepository,
|
private val repository: AdminRequestsRepository,
|
||||||
private val usersRepository: AdminUsersRepository,
|
private val usersRepository: AdminUsersRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
private val internal = MutableStateFlow<AdminRequestsUiState>(AdminRequestsUiState.Loading)
|
||||||
@@ -47,6 +50,13 @@ class AdminRequestsViewModel @Inject constructor(
|
|||||||
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
.filter { it.kind in RELEVANT_EVENT_KINDS }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminRequestsUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.AdminUserRef
|
import com.fabledsword.minstrel.models.AdminUserRef
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -23,6 +25,7 @@ sealed interface AdminUsersUiState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class AdminUsersViewModel @Inject constructor(
|
class AdminUsersViewModel @Inject constructor(
|
||||||
private val repository: AdminUsersRepository,
|
private val repository: AdminUsersRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
private val internal = MutableStateFlow<AdminUsersUiState>(AdminUsersUiState.Loading)
|
||||||
@@ -30,6 +33,13 @@ class AdminUsersViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): reload a failed list
|
||||||
|
// when server health returns instead of waiting for a manual pull.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminUsersUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
+18
-1
@@ -1,5 +1,6 @@
|
|||||||
package com.fabledsword.minstrel.cache.sync
|
package com.fabledsword.minstrel.cache.sync
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
import com.fabledsword.minstrel.api.endpoints.SyncApi
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
import com.fabledsword.minstrel.auth.AuthStore
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
@@ -17,6 +18,9 @@ import com.fabledsword.minstrel.models.wire.SyncAlbumWire
|
|||||||
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
import com.fabledsword.minstrel.models.wire.SyncArtistWire
|
||||||
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
import com.fabledsword.minstrel.models.wire.SyncTrackWire
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
@@ -64,6 +68,17 @@ class SyncController @Inject constructor(
|
|||||||
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
// pull-to-refresh) can fire concurrently; coalesce them into one pass.
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
private val lastSyncErrorInternal = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human copy for the most recent sync failure; null after any clean
|
||||||
|
* pass. Lets the Library screen distinguish "empty because the first
|
||||||
|
* sync failed" (show error + retry) from "genuinely empty library"
|
||||||
|
* (show welcome copy) — a failed sync over a populated cache stays
|
||||||
|
* silent, since the cached content is still the better surface.
|
||||||
|
*/
|
||||||
|
val lastSyncError: StateFlow<String?> = lastSyncErrorInternal.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
authStore.sessionCookie
|
authStore.sessionCookie
|
||||||
@@ -82,11 +97,13 @@ class SyncController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Public entry-point for "Sync now" affordances. Swallows errors. */
|
/** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */
|
||||||
suspend fun syncSafe() {
|
suspend fun syncSafe() {
|
||||||
if (!mutex.tryLock()) return
|
if (!mutex.tryLock()) return
|
||||||
try {
|
try {
|
||||||
runCatching { sync() }
|
runCatching { sync() }
|
||||||
|
.onSuccess { lastSyncErrorInternal.value = null }
|
||||||
|
.onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
|
||||||
} finally {
|
} finally {
|
||||||
mutex.unlock()
|
mutex.unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.fabledsword.minstrel.connectivity
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emits once each time server health RETURNS to [ServerHealth.Healthy]
|
||||||
|
* after the collector subscribed — the StateFlow's replayed current value
|
||||||
|
* is dropped, so only genuine down→up transitions fire (a screen that
|
||||||
|
* subscribes while already Healthy doesn't double-load).
|
||||||
|
*
|
||||||
|
* This is the screen-level half of the recovery idiom the app-lifetime
|
||||||
|
* singletons (SyncController / MutationReplayer / DiagnosticsUploader)
|
||||||
|
* already use: a ViewModel collects this in its viewModelScope and re-runs
|
||||||
|
* its load, so a surface that failed while the server was unreachable
|
||||||
|
* heals itself the moment connectivity returns instead of sitting in the
|
||||||
|
* failed state until a manual pull-to-refresh.
|
||||||
|
*/
|
||||||
|
fun NetworkStatusController.recoveries(): Flow<Unit> = state
|
||||||
|
.map { it == ServerHealth.Healthy }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.drop(1)
|
||||||
|
.filter { it }
|
||||||
|
.map { }
|
||||||
@@ -41,6 +41,7 @@ import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
|||||||
import com.fabledsword.minstrel.models.LidarrRequestKind
|
import com.fabledsword.minstrel.models.LidarrRequestKind
|
||||||
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
import com.fabledsword.minstrel.models.LidarrSearchResultRef
|
||||||
import com.fabledsword.minstrel.nav.Discover
|
import com.fabledsword.minstrel.nav.Discover
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -109,6 +110,7 @@ private fun DiscoverBody(
|
|||||||
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
snackbar.showSnackbar(snackbarFor(outcome, s.name))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onRetry = { viewModel.loadSuggestions() },
|
||||||
)
|
)
|
||||||
ResultsState.Loading -> LoadingCentered()
|
ResultsState.Loading -> LoadingCentered()
|
||||||
is ResultsState.Loaded -> ResultsList(
|
is ResultsState.Loaded -> ResultsList(
|
||||||
@@ -121,7 +123,11 @@ private fun DiscoverBody(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
is ResultsState.Error -> CenteredMessage("Search failed: ${r.message}")
|
is ResultsState.Error -> ErrorRetry(
|
||||||
|
title = "Search failed",
|
||||||
|
message = r.message,
|
||||||
|
onRetry = viewModel::runSearch,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,10 +183,15 @@ private fun SuggestionsPane(
|
|||||||
state: SuggestionState,
|
state: SuggestionState,
|
||||||
locallyRequestedMbids: Set<String>,
|
locallyRequestedMbids: Set<String>,
|
||||||
onRequest: (ArtistSuggestionRef) -> Unit,
|
onRequest: (ArtistSuggestionRef) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SuggestionState.Loading -> LoadingCentered()
|
SuggestionState.Loading -> LoadingCentered()
|
||||||
is SuggestionState.Error -> CenteredMessage("Couldn't load suggestions.")
|
is SuggestionState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load suggestions",
|
||||||
|
message = state.message,
|
||||||
|
onRetry = onRetry,
|
||||||
|
)
|
||||||
is SuggestionState.Loaded -> SuggestionsList(
|
is SuggestionState.Loaded -> SuggestionsList(
|
||||||
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
items = state.items.filter { it.mbid !in locallyRequestedMbids },
|
||||||
onRequest = onRequest,
|
onRequest = onRequest,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.discover.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
import com.fabledsword.minstrel.discover.data.DiscoverRepository
|
||||||
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
import com.fabledsword.minstrel.discover.data.RequestOutcome
|
||||||
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
import com.fabledsword.minstrel.models.ArtistSuggestionRef
|
||||||
@@ -46,6 +48,7 @@ sealed interface ResultsState {
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class DiscoverViewModel @Inject constructor(
|
class DiscoverViewModel @Inject constructor(
|
||||||
private val repository: DiscoverRepository,
|
private val repository: DiscoverRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow(DiscoverState())
|
private val internal = MutableStateFlow(DiscoverState())
|
||||||
@@ -53,6 +56,15 @@ class DiscoverViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
loadSuggestions()
|
loadSuggestions()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-run whichever pane
|
||||||
|
// failed while the server was unreachable once health returns.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
val s = internal.value
|
||||||
|
if (s.suggestions is SuggestionState.Error) loadSuggestions()
|
||||||
|
if (s.results is ResultsState.Error) runSearch()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.history.data.HistoryEntry
|
import com.fabledsword.minstrel.history.data.HistoryEntry
|
||||||
import com.fabledsword.minstrel.history.data.HistoryRepository
|
import com.fabledsword.minstrel.history.data.HistoryRepository
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
@@ -26,6 +28,7 @@ import com.fabledsword.minstrel.player.PlayerController
|
|||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -56,6 +59,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HistoryTabViewModel @Inject constructor(
|
class HistoryTabViewModel @Inject constructor(
|
||||||
private val repository: HistoryRepository,
|
private val repository: HistoryRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -82,6 +86,11 @@ class HistoryTabViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -116,9 +125,10 @@ fun HistoryTab(
|
|||||||
title = "No listening history yet",
|
title = "No listening history yet",
|
||||||
body = "Play something — your recent plays will show up here.",
|
body = "Play something — your recent plays will show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load history",
|
title = "Couldn't load history",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HistoryList(
|
is UiState.Success -> HistoryList(
|
||||||
entries = s.data,
|
entries = s.data,
|
||||||
|
|||||||
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
package com.fabledsword.minstrel.home.ui
|
package com.fabledsword.minstrel.home.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -27,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -55,6 +64,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.composables.icons.lucide.Music
|
import com.composables.icons.lucide.Music
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.home.data.HomeRepository
|
import com.fabledsword.minstrel.home.data.HomeRepository
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
@@ -77,6 +87,7 @@ import com.fabledsword.minstrel.playlists.widgets.PlaylistPlaceholderCard
|
|||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -86,6 +97,7 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -108,6 +120,16 @@ private const val BOTTOM_PADDING_FOR_MINIPLAYER_DP = 140
|
|||||||
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
private const val RECENTLY_ADDED_GRID_ROWS = 2
|
||||||
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
||||||
|
|
||||||
|
// "Updating your mixes…" veil (automatic refresh). Held through the pull
|
||||||
|
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
|
||||||
|
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
||||||
|
private const val VEIL_SETTLE_MS = 500L
|
||||||
|
private const val VEIL_WIPE_MS = 280
|
||||||
|
private const val VEIL_ALPHA = 0.96f
|
||||||
|
private const val VEIL_SPINNER_DP = 22
|
||||||
|
private const val VEIL_SPINNER_STROKE_DP = 2
|
||||||
|
private const val VEIL_LABEL_GAP_DP = 12
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class HomeSections(
|
data class HomeSections(
|
||||||
@@ -167,16 +189,44 @@ class HomeViewModel @Inject constructor(
|
|||||||
/** Transient snackbar messages from offline-pool taps. */
|
/** Transient snackbar messages from offline-pool taps. */
|
||||||
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
val transientMessages: Flow<String> = poolMessages.receiveAsFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy for the most recent /home/index refresh failure; null once a
|
||||||
|
* refresh succeeds. Folded into [uiState] so an empty cache + failed
|
||||||
|
* refresh renders as Error (with retry) instead of masquerading as
|
||||||
|
* the "Welcome to Minstrel" empty state.
|
||||||
|
*/
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
private val updatingInternal = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while an automatic background refresh (the 03:00 daily rebuild
|
||||||
|
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
|
||||||
|
* your mixes…" veil so the section churn — delete-then-insert in
|
||||||
|
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
|
||||||
|
* hidden behind the veil instead of on screen. Manual pull-to-refresh
|
||||||
|
* and cold start are NOT veiled (they own the pull spinner / skeleton).
|
||||||
|
*/
|
||||||
|
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
||||||
|
// load while the server was unreachable re-pulls itself the moment
|
||||||
|
// health returns — same idiom as SyncController, one layer up.
|
||||||
|
// Veiled: content is already on screen and would otherwise churn.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refreshBehindVeil() }
|
||||||
|
}
|
||||||
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
||||||
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
||||||
// and You-might-like rows reflect the new snapshot without a manual
|
// and You-might-like rows reflect the new snapshot without a manual
|
||||||
// reload. Mirrors the web SSE consumer.
|
// reload. Mirrors the web SSE consumer. Veiled so the multi-section
|
||||||
|
// rebuild churn hides behind "Updating your mixes…".
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
eventsStream.events
|
eventsStream.events
|
||||||
.filter { it.kind == "playlist.system_rebuilt" }
|
.filter { it.kind == "playlist.system_rebuilt" }
|
||||||
.collect { refresh() }
|
.collect { refreshBehindVeil() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +339,14 @@ class HomeViewModel @Inject constructor(
|
|||||||
* actual completion before hiding the indicator.
|
* actual completion before hiding the indicator.
|
||||||
*/
|
*/
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
val home = launch { runCatching { homeRepository.refreshIndex() } }
|
refreshError.value = null
|
||||||
|
val home = launch {
|
||||||
|
// /home/index is the load-bearing pull: its failure drives the
|
||||||
|
// empty-cache Error state. A failure over a populated cache
|
||||||
|
// stays silent — cached sections beat a full-screen error.
|
||||||
|
runCatching { homeRepository.refreshIndex() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
|
}
|
||||||
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
val lists = launch { runCatching { playlistsRepository.refreshList() } }
|
||||||
val status = launch {
|
val status = launch {
|
||||||
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
runCatching { homeRepository.getSystemPlaylistsStatus() }
|
||||||
@@ -300,6 +357,26 @@ class HomeViewModel @Inject constructor(
|
|||||||
status.join()
|
status.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic background refresh with the "Updating your mixes…" veil
|
||||||
|
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
|
||||||
|
* paths where Home is already on screen. Holds the veil through the
|
||||||
|
* pull plus a short settle so per-tile hydration lands behind it, then
|
||||||
|
* lets it wipe off. Overlapping automatic refreshes are rare enough
|
||||||
|
* (once-daily rebuild, reconnect) that a plain flag beats a counter.
|
||||||
|
*/
|
||||||
|
private fun refreshBehindVeil() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
updatingInternal.value = true
|
||||||
|
try {
|
||||||
|
refresh().join()
|
||||||
|
delay(VEIL_SETTLE_MS)
|
||||||
|
} finally {
|
||||||
|
updatingInternal.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<HomeSections>> =
|
val uiState: StateFlow<UiState<HomeSections>> =
|
||||||
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
@@ -336,9 +413,17 @@ class HomeViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun combineHomeFlows() =
|
private fun combineHomeFlows() =
|
||||||
observeHomeSections().combine(playlistsRepository.observeAll()) { sections, playlists ->
|
combine(
|
||||||
|
observeHomeSections(),
|
||||||
|
playlistsRepository.observeAll(),
|
||||||
|
refreshError,
|
||||||
|
) { sections, playlists, err ->
|
||||||
val merged = sections.copy(playlists = playlists)
|
val merged = sections.copy(playlists = playlists)
|
||||||
if (merged.isAllEmpty) UiState.Empty else UiState.Success(merged)
|
when {
|
||||||
|
!merged.isAllEmpty -> UiState.Success(merged)
|
||||||
|
err != null -> UiState.Error(err)
|
||||||
|
else -> UiState.Empty
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,46 +452,108 @@ fun HomeScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
|
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refresh().join() },
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
// Key Crossfade on the state CLASS, not the instance. Each
|
Box(Modifier.fillMaxSize()) {
|
||||||
// section emission produces a new UiState.Success(data); if
|
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||||
// we keyed on `state` directly, every per-section
|
// Automatic-refresh veil: the daily rebuild / reconnect
|
||||||
// hydration tick would re-run the 300ms crossfade, and
|
// churn hides behind an "Updating your mixes…" wipe. Manual
|
||||||
// first-sign-in (six sections cascading in) reads as
|
// pull owns the PullToRefreshBox spinner instead.
|
||||||
// continuous flicker. Keying on the class restricts the
|
UpdatingVeil(visible = updating)
|
||||||
// animation to Loading↔Success↔Empty↔Error transitions and
|
}
|
||||||
// lets normal Success→Success recompositions update the
|
}
|
||||||
// LazyColumn without a fade.
|
}
|
||||||
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
}
|
||||||
when (val s = state) {
|
|
||||||
UiState.Loading -> HomeSkeletonContent()
|
/**
|
||||||
UiState.Empty -> EmptyState(
|
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
|
||||||
title = "Welcome to Minstrel",
|
*
|
||||||
body = "Nothing to show yet — scan a folder in your server " +
|
* Key the Crossfade on the state CLASS, not the instance. Each section
|
||||||
"settings, then come back here for system playlists " +
|
* emission produces a new UiState.Success(data); if we keyed on `state`
|
||||||
"and recommendations.",
|
* directly, every per-section hydration tick would re-run the 300ms
|
||||||
)
|
* crossfade, and first-sign-in (six sections cascading in) reads as
|
||||||
is UiState.Error -> EmptyState(
|
* continuous flicker. Keying on the class restricts the animation to
|
||||||
title = "Couldn't load home",
|
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
|
||||||
body = s.message,
|
* recompositions update the LazyColumn without a fade.
|
||||||
)
|
*/
|
||||||
is UiState.Success -> HomeSuccessContent(
|
@Composable
|
||||||
sections = s.data,
|
private fun HomeStateCrossfade(
|
||||||
systemStatus = systemStatus,
|
state: UiState<HomeSections>,
|
||||||
offline = offline,
|
systemStatus: SystemPlaylistsStatus,
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
offline: Boolean,
|
||||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
navController: NavHostController,
|
||||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
viewModel: HomeViewModel,
|
||||||
onMostPlayedTap = viewModel::playMostPlayed,
|
) {
|
||||||
onPlayPool = viewModel::playPool,
|
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
||||||
onPlayAlbum = viewModel::playAlbum,
|
when (val s = state) {
|
||||||
onPlayArtist = viewModel::playArtistShuffled,
|
UiState.Loading -> HomeSkeletonContent()
|
||||||
onPlayPlaylist = viewModel::playPlaylist,
|
UiState.Empty -> EmptyState(
|
||||||
)
|
title = "Welcome to Minstrel",
|
||||||
}
|
body = "Nothing to show yet — scan a folder in your server " +
|
||||||
|
"settings, then come back here for system playlists " +
|
||||||
|
"and recommendations.",
|
||||||
|
)
|
||||||
|
is UiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load home",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
|
)
|
||||||
|
is UiState.Success -> HomeSuccessContent(
|
||||||
|
sections = s.data,
|
||||||
|
systemStatus = systemStatus,
|
||||||
|
offline = offline,
|
||||||
|
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
|
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
||||||
|
onMostPlayedTap = viewModel::playMostPlayed,
|
||||||
|
onPlayPool = viewModel::playPool,
|
||||||
|
onPlayAlbum = viewModel::playAlbum,
|
||||||
|
onPlayArtist = viewModel::playArtistShuffled,
|
||||||
|
onPlayPlaylist = viewModel::playPlaylist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-bleed "Updating your mixes…" veil that wipes in from the left,
|
||||||
|
* holds while an automatic refresh repopulates Home, then wipes off.
|
||||||
|
* Near-opaque so the section churn underneath never shows; swallows taps
|
||||||
|
* while raised so a mid-hydration tile can't be hit.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun BoxScope.UpdatingVeil(visible: Boolean) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = slideInHorizontally(tween(VEIL_WIPE_MS)) { -it } + fadeIn(tween(VEIL_WIPE_MS)),
|
||||||
|
exit = slideOutHorizontally(tween(VEIL_WIPE_MS)) { it } + fadeOut(tween(VEIL_WIPE_MS)),
|
||||||
|
modifier = Modifier.matchParentSize(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.background.copy(alpha = VEIL_ALPHA))
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
) {},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(VEIL_SPINNER_DP.dp),
|
||||||
|
strokeWidth = VEIL_SPINNER_STROKE_DP.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(VEIL_LABEL_GAP_DP.dp))
|
||||||
|
Text(
|
||||||
|
text = "Updating your mixes…",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,10 +623,12 @@ private fun HomeSuccessContent(
|
|||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
// Always rendered: real system/user playlists, with
|
// Always rendered: real system/user playlists, with
|
||||||
// placeholder cards filling the For You / Discover /
|
// placeholder cards filling the For You / Discover slots
|
||||||
// 3× Songs-like slots that haven't generated yet. When
|
// that haven't generated yet. When offline, the cache-backed
|
||||||
// offline, the cache-backed pool cards lead the row.
|
// pool cards lead the row. Songs-like now lives in its own
|
||||||
|
// dedicated row below (#1491), no longer inside this carousel.
|
||||||
PlaylistsRow(
|
PlaylistsRow(
|
||||||
|
title = "Playlists",
|
||||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||||
offline = offline,
|
offline = offline,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
@@ -487,6 +636,18 @@ private fun HomeSuccessContent(
|
|||||||
onPlayPlaylist = onPlayPlaylist,
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Songs-like is the best-performing surface (#1491) — promoted out
|
||||||
|
// of the Playlists carousel into its own row so it shows a wider
|
||||||
|
// spread of "Songs like {artist}" mixes. Hidden when there's
|
||||||
|
// nothing to show (offline with none cached).
|
||||||
|
songsLikeSection(
|
||||||
|
playlists = sections.playlists,
|
||||||
|
status = systemStatus,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
youMightLikeSection(
|
youMightLikeSection(
|
||||||
albums = sections.youMightLikeAlbums,
|
albums = sections.youMightLikeAlbums,
|
||||||
artists = sections.youMightLikeArtists,
|
artists = sections.youMightLikeArtists,
|
||||||
@@ -510,6 +671,35 @@ private fun HomeSuccessContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
|
||||||
|
* rendering with a distinct title; hidden entirely when there's nothing
|
||||||
|
* to show (offline with no cached mixes). Online with none generated yet
|
||||||
|
* still shows a few placeholders so the building / seed-needed state is
|
||||||
|
* visible, matching the pre-promotion carousel behavior.
|
||||||
|
*/
|
||||||
|
private fun LazyListScope.songsLikeSection(
|
||||||
|
playlists: List<PlaylistRef>,
|
||||||
|
status: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
onPlaylistClick: (String) -> Unit,
|
||||||
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
|
) {
|
||||||
|
val rowItems = buildSongsLikeRow(playlists, status, offline)
|
||||||
|
if (rowItems.isEmpty()) return
|
||||||
|
item {
|
||||||
|
PlaylistsRow(
|
||||||
|
title = "Songs like…",
|
||||||
|
rowItems = rowItems,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun LazyListScope.recentlyAddedSection(
|
private fun LazyListScope.recentlyAddedSection(
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
albums: List<HomeTile<AlbumRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
@@ -779,13 +969,14 @@ private fun AlbumsRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaylistsRow(
|
private fun PlaylistsRow(
|
||||||
|
title: String,
|
||||||
rowItems: List<PlaylistRowItem>,
|
rowItems: List<PlaylistRowItem>,
|
||||||
offline: Boolean,
|
offline: Boolean,
|
||||||
onPlaylistClick: (String) -> Unit,
|
onPlaylistClick: (String) -> Unit,
|
||||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
HorizontalScrollRow(title = "Playlists") {
|
HorizontalScrollRow(title = title) {
|
||||||
itemsIndexed(items = rowItems) { _, item ->
|
itemsIndexed(items = rowItems) { _, item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
||||||
@@ -836,11 +1027,12 @@ enum class OfflinePoolKind(val label: String) {
|
|||||||
/**
|
/**
|
||||||
* Builds the Home Playlists row.
|
* Builds the Home Playlists row.
|
||||||
*
|
*
|
||||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
* Online: For You + Discover fixed slots (real card when generated,
|
||||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
* rediscover / new for you / on this day / first listens) when they exist —
|
||||||
* no placeholders for these since they're conditional on library shape — then
|
* no placeholders for these since they're conditional on library shape — then
|
||||||
* user-owned playlists.
|
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
|
||||||
|
* [buildSongsLikeRow] and no longer appears in this carousel.
|
||||||
*
|
*
|
||||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||||
* same real playlists in curated order but stably partitioned fully-cached
|
* same real playlists in curated order but stably partitioned fully-cached
|
||||||
@@ -867,7 +1059,7 @@ internal fun buildPlaylistsRow(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
||||||
private fun buildOnlineRow(
|
private fun buildOnlineRow(
|
||||||
owned: List<PlaylistRef>,
|
owned: List<PlaylistRef>,
|
||||||
status: SystemPlaylistsStatus,
|
status: SystemPlaylistsStatus,
|
||||||
@@ -879,12 +1071,7 @@ private fun buildOnlineRow(
|
|||||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
||||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
|
||||||
out += songsLike.getOrNull(i)
|
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
|
||||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
|
||||||
}
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||||
}
|
}
|
||||||
@@ -892,16 +1079,44 @@ private fun buildOnlineRow(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
||||||
|
* mixes the server generated, no longer capped to the 3 carousel slots.
|
||||||
|
*
|
||||||
|
* Online: every generated mix as a real card; when none exist yet, a few
|
||||||
|
* placeholders so the building / seed-needed state stays visible.
|
||||||
|
* Offline: the cached mixes only (fully-cached first, greyed after), and
|
||||||
|
* an empty list — hiding the whole section — when nothing is cached.
|
||||||
|
*/
|
||||||
|
internal fun buildSongsLikeRow(
|
||||||
|
owned: List<PlaylistRef>,
|
||||||
|
status: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
): List<PlaylistRowItem> {
|
||||||
|
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
|
||||||
|
return when {
|
||||||
|
offline -> {
|
||||||
|
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||||
|
(available + greyed).map { PlaylistRowItem.Real(it) }
|
||||||
|
}
|
||||||
|
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
|
||||||
|
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
|
||||||
|
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||||
* only differs by dropping placeholders + partitioning available-first.
|
* only differs by dropping placeholders + partitioning available-first.
|
||||||
|
* Songs-like is excluded here too — it renders in its own row via
|
||||||
|
* [buildSongsLikeRow] in both online and offline modes (#1491).
|
||||||
*/
|
*/
|
||||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||||
val out = mutableListOf<PlaylistRef>()
|
val out = mutableListOf<PlaylistRef>()
|
||||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||||
}
|
}
|
||||||
@@ -916,7 +1131,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
|||||||
else -> "pending"
|
else -> "pending"
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SONGS_LIKE_SLOTS = 3
|
// How many "Songs like…" placeholder cards the dedicated row shows while
|
||||||
|
// the mixes haven't generated yet (building / seed-needed). Real mixes,
|
||||||
|
// once generated, are shown in full and no longer capped by this (#1491).
|
||||||
|
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 5 system playlist kinds the server generates that aren't pinned
|
* The 5 system playlist kinds the server generates that aren't pinned
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
import com.fabledsword.minstrel.shared.widgets.ServerImage
|
||||||
@@ -116,9 +116,10 @@ private fun AlbumDetailStateContent(
|
|||||||
when (val s = state) {
|
when (val s = state) {
|
||||||
is AlbumDetailUiState.Loading ->
|
is AlbumDetailUiState.Loading ->
|
||||||
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
if (s.seed != null) SeededAlbumLoading(s.seed) else SkeletonTrackList()
|
||||||
is AlbumDetailUiState.Error -> EmptyState(
|
is AlbumDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load album",
|
title = "Couldn't load album",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is AlbumDetailUiState.Success -> {
|
is AlbumDetailUiState.Success -> {
|
||||||
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
val albumLiked by viewModel.albumLiked.collectAsStateWithLifecycle()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.AlbumDetailRef
|
import com.fabledsword.minstrel.models.AlbumDetailRef
|
||||||
@@ -36,6 +38,7 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -64,6 +67,14 @@ class AlbumDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||||
|
// load while the server was unreachable re-fetches when health
|
||||||
|
// returns, so coming back to the screen shows content, not an error.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AlbumDetailUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ import com.fabledsword.minstrel.models.albumCoverPath
|
|||||||
import com.fabledsword.minstrel.nav.AlbumDetail
|
import com.fabledsword.minstrel.nav.AlbumDetail
|
||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -107,9 +107,10 @@ fun ArtistDetailScreen(
|
|||||||
} else {
|
} else {
|
||||||
SkeletonArtistAlbumsGrid()
|
SkeletonArtistAlbumsGrid()
|
||||||
}
|
}
|
||||||
is ArtistDetailUiState.Error -> EmptyState(
|
is ArtistDetailUiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load artist",
|
title = "Couldn't load artist",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is ArtistDetailUiState.Success ->
|
is ArtistDetailUiState.Success ->
|
||||||
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
ArtistSuccessBody(s, viewModel, playerViewModel, navController)
|
||||||
|
|||||||
+16
@@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.toRoute
|
import androidx.navigation.toRoute
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.library.data.LibraryRepository
|
import com.fabledsword.minstrel.library.data.LibraryRepository
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.models.ArtistDetailRef
|
import com.fabledsword.minstrel.models.ArtistDetailRef
|
||||||
@@ -45,6 +47,7 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
private val likes: LikesRepository,
|
private val likes: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -69,6 +72,19 @@ class ArtistDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245). From Error, redo the
|
||||||
|
// whole load; from Success, re-pull just the secondary sections —
|
||||||
|
// their fetch failures are swallowed to emptyList, so a dead zone
|
||||||
|
// during the first load leaves them permanently absent otherwise.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
when (internal.value) {
|
||||||
|
is ArtistDetailUiState.Error -> refresh()
|
||||||
|
is ArtistDetailUiState.Success -> loadSecondarySections()
|
||||||
|
is ArtistDetailUiState.Loading -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggleLikeArtist() {
|
fun toggleLikeArtist() {
|
||||||
|
|||||||
@@ -24,11 +24,17 @@ class LibraryViewModel @Inject constructor(
|
|||||||
combine(
|
combine(
|
||||||
repository.observeArtists(),
|
repository.observeArtists(),
|
||||||
repository.observeAlbums(),
|
repository.observeAlbums(),
|
||||||
) { artists, albums ->
|
syncController.lastSyncError,
|
||||||
if (artists.isEmpty() && albums.isEmpty()) {
|
) { artists, albums, syncError ->
|
||||||
UiState.Empty
|
when {
|
||||||
} else {
|
artists.isNotEmpty() || albums.isNotEmpty() ->
|
||||||
UiState.Success(LibraryData(artists, albums))
|
UiState.Success(LibraryData(artists, albums))
|
||||||
|
// Empty cache + failed sync is a load failure, not an empty
|
||||||
|
// library — surface it so the user gets a Retry instead of
|
||||||
|
// the welcome copy. Heals automatically: SyncController
|
||||||
|
// re-syncs on health recovery, clearing lastSyncError.
|
||||||
|
syncError != null -> UiState.Error(syncError)
|
||||||
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
import com.fabledsword.minstrel.library.widgets.AlbumCard
|
||||||
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
import com.fabledsword.minstrel.library.widgets.ArtistCard
|
||||||
@@ -38,6 +41,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
import com.fabledsword.minstrel.shared.widgets.HorizontalScrollRow
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -46,6 +50,7 @@ import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
|||||||
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -68,14 +73,25 @@ data class LikedSections(
|
|||||||
class LikedTabViewModel @Inject constructor(
|
class LikedTabViewModel @Inject constructor(
|
||||||
private val repository: LikesRepository,
|
private val repository: LikesRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
/** Copy for the most recent refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshIds() }
|
runCatching { repository.refreshIds() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -92,9 +108,16 @@ class LikedTabViewModel @Inject constructor(
|
|||||||
repository.observeLikedArtists(),
|
repository.observeLikedArtists(),
|
||||||
repository.observeLikedAlbums(),
|
repository.observeLikedAlbums(),
|
||||||
repository.observeLikedTracks(),
|
repository.observeLikedTracks(),
|
||||||
) { artists, albums, tracks ->
|
refreshError,
|
||||||
|
) { artists, albums, tracks, err ->
|
||||||
val sections = LikedSections(artists, albums, tracks)
|
val sections = LikedSections(artists, albums, tracks)
|
||||||
if (sections.isAllEmpty) UiState.Empty else UiState.Success(sections)
|
when {
|
||||||
|
!sections.isAllEmpty -> UiState.Success(sections)
|
||||||
|
// Empty cache + failed refresh is a load failure, not "no
|
||||||
|
// likes yet" — surface Error so the user gets a Retry.
|
||||||
|
err != null -> UiState.Error(err)
|
||||||
|
else -> UiState.Empty
|
||||||
|
}
|
||||||
}.asCacheFirstStateFlow(viewModelScope)
|
}.asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -130,9 +153,10 @@ fun LikedTab(
|
|||||||
body = "Tap the heart on an artist, album, or track to start " +
|
body = "Tap the heart on an artist, album, or track to start " +
|
||||||
"building your liked collection.",
|
"building your liked collection.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load likes",
|
title = "Couldn't load likes",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> LikedContent(
|
is UiState.Success -> LikedContent(
|
||||||
sections = s.data,
|
sections = s.data,
|
||||||
|
|||||||
+17
-30
@@ -38,9 +38,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -60,6 +58,8 @@ import com.composables.icons.lucide.Play
|
|||||||
import com.composables.icons.lucide.RefreshCw
|
import com.composables.icons.lucide.RefreshCw
|
||||||
import com.composables.icons.lucide.Shuffle
|
import com.composables.icons.lucide.Shuffle
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.events.LiveEvent
|
import com.fabledsword.minstrel.events.LiveEvent
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
@@ -76,6 +76,7 @@ import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
|
|||||||
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
|
||||||
import com.fabledsword.minstrel.shared.formatDuration
|
import com.fabledsword.minstrel.shared.formatDuration
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
import com.fabledsword.minstrel.shared.widgets.LikeButton
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
import com.fabledsword.minstrel.shared.widgets.SkeletonTrackRow
|
||||||
@@ -117,6 +118,7 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
private val seedCache: DetailSeedCache,
|
private val seedCache: DetailSeedCache,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -167,6 +169,14 @@ class PlaylistDetailViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { handlePlaylistEvent(it) }
|
.collect { handlePlaylistEvent(it) }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): a detail that failed to
|
||||||
|
// load while the server was unreachable re-fetches when health
|
||||||
|
// returns, instead of waiting for the Retry tap.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is PlaylistDetailUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -369,7 +379,11 @@ private fun PlaylistDetailContent(
|
|||||||
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
Crossfade(targetState = state::class, label = "playlist-detail") { _ -> when (val s = state) {
|
||||||
is PlaylistDetailUiState.Loading ->
|
is PlaylistDetailUiState.Loading ->
|
||||||
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
s.seed?.let { SeededPlaylistLoading(it) } ?: SkeletonPlaylistTrackList()
|
||||||
is PlaylistDetailUiState.Error -> ErrorBlock(s.message, viewModel::refresh)
|
is PlaylistDetailUiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load playlist",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
|
)
|
||||||
is PlaylistDetailUiState.Success -> {
|
is PlaylistDetailUiState.Success -> {
|
||||||
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
val likedTrackIds by viewModel.likedTrackIds.collectAsState()
|
||||||
val playerState by playerViewModel.uiState.collectAsState()
|
val playerState by playerViewModel.uiState.collectAsState()
|
||||||
@@ -653,33 +667,6 @@ private fun SeededPlaylistLoading(seed: PlaylistRef) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ErrorBlock(message: String, onRetry: () -> Unit) {
|
|
||||||
var retried by remember { mutableStateOf(false) }
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = message,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
if (!retried) {
|
|
||||||
retried = true
|
|
||||||
onRetry()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { Text("Retry") }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private const val UNAVAILABLE_ALPHA = 0.4f
|
private const val UNAVAILABLE_ALPHA = 0.4f
|
||||||
|
|||||||
+26
-9
@@ -27,8 +27,10 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
import com.fabledsword.minstrel.connectivity.ServerHealth
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.PlaylistRef
|
import com.fabledsword.minstrel.models.PlaylistRef
|
||||||
import com.fabledsword.minstrel.nav.PlaylistDetail
|
import com.fabledsword.minstrel.nav.PlaylistDetail
|
||||||
@@ -40,6 +42,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
import com.fabledsword.minstrel.shared.asCacheFirstStateFlow
|
||||||
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
import com.fabledsword.minstrel.playlists.widgets.PlaylistCard
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -47,8 +50,10 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.receiveAsFlow
|
import kotlinx.coroutines.flow.receiveAsFlow
|
||||||
@@ -84,6 +89,9 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
initialValue = false,
|
initialValue = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Copy for the most recent list-refresh failure; null once one succeeds. */
|
||||||
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Live updates: a playlist created/updated/deleted from another
|
// Live updates: a playlist created/updated/deleted from another
|
||||||
@@ -93,11 +101,18 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("playlist.") }
|
.filter { it.kind.startsWith("playlist.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
/** Re-pull the playlists list. Returns the Job for pull-to-refresh awaits. */
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
refreshError.value = null
|
||||||
runCatching { repository.refreshList() }
|
runCatching { repository.refreshList() }
|
||||||
|
.onFailure { refreshError.value = ErrorCopy.fromThrowable(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
/** Tile play button: shuffle the playlist's tracks and start at index 0. */
|
||||||
@@ -110,14 +125,15 @@ class PlaylistsListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
val uiState: StateFlow<UiState<List<PlaylistRef>>> =
|
||||||
repository.observeAll()
|
combine(repository.observeAll(), refreshError) { list, err ->
|
||||||
.map { list ->
|
when {
|
||||||
if (list.isEmpty()) {
|
list.isNotEmpty() -> UiState.Success(list)
|
||||||
UiState.Empty
|
// Empty cache + failed refresh is a load failure, not "no
|
||||||
} else {
|
// playlists yet" — surface Error so the user gets a Retry.
|
||||||
UiState.Success(list)
|
err != null -> UiState.Error(err)
|
||||||
}
|
else -> UiState.Empty
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.asCacheFirstStateFlow(viewModelScope)
|
.asCacheFirstStateFlow(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,9 +173,10 @@ fun PlaylistsListScreen(
|
|||||||
"appear once your library has enough plays. Create your " +
|
"appear once your library has enough plays. Create your " +
|
||||||
"own playlist from any album or track in the meantime.",
|
"own playlist from any album or track in the meantime.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load playlists",
|
title = "Couldn't load playlists",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> PlaylistsGrid(
|
is UiState.Success -> PlaylistsGrid(
|
||||||
playlists = s.data,
|
playlists = s.data,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import com.composables.icons.lucide.Lucide
|
|||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -43,9 +44,10 @@ fun HiddenTab(viewModel: HiddenTabViewModel = hiltViewModel()) {
|
|||||||
"and it'll show up here. The track stays out of system " +
|
"and it'll show up here. The track stays out of system " +
|
||||||
"playlists until you unhide it.",
|
"playlists until you unhide it.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load hidden tracks",
|
title = "Couldn't load hidden tracks",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> HiddenList(
|
is UiState.Success -> HiddenList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.quarantine.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.QuarantineRef
|
import com.fabledsword.minstrel.models.QuarantineRef
|
||||||
import com.fabledsword.minstrel.shared.UiState
|
import com.fabledsword.minstrel.shared.UiState
|
||||||
@@ -24,6 +26,7 @@ private const val SHARE_STOP_TIMEOUT_MS = 5_000L
|
|||||||
class HiddenTabViewModel @Inject constructor(
|
class HiddenTabViewModel @Inject constructor(
|
||||||
private val repository: QuarantineRepository,
|
private val repository: QuarantineRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
@@ -61,6 +64,11 @@ class HiddenTabViewModel @Inject constructor(
|
|||||||
.filter { it.kind.startsWith("quarantine.") }
|
.filter { it.kind.startsWith("quarantine.") }
|
||||||
.collect { refresh() }
|
.collect { refresh() }
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): re-pull when server
|
||||||
|
// health returns, so a load that failed offline heals unprompted.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect { refresh() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import com.fabledsword.minstrel.shared.UiState
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Requests
|
import com.fabledsword.minstrel.nav.Requests
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
@@ -78,9 +79,10 @@ fun RequestsScreen(
|
|||||||
body = "Use Discover to ask Lidarr for new music; your " +
|
body = "Use Discover to ask Lidarr for new music; your " +
|
||||||
"requests show up here.",
|
"requests show up here.",
|
||||||
)
|
)
|
||||||
is UiState.Error -> EmptyState(
|
is UiState.Error -> ErrorRetry(
|
||||||
title = "Couldn't load requests",
|
title = "Couldn't load requests",
|
||||||
body = s.message,
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
)
|
)
|
||||||
is UiState.Success -> RequestList(
|
is UiState.Success -> RequestList(
|
||||||
rows = s.data,
|
rows = s.data,
|
||||||
|
|||||||
+16
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.requests.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.events.EventsStream
|
import com.fabledsword.minstrel.events.EventsStream
|
||||||
import com.fabledsword.minstrel.models.RequestRef
|
import com.fabledsword.minstrel.models.RequestRef
|
||||||
import com.fabledsword.minstrel.models.RequestStatus
|
import com.fabledsword.minstrel.models.RequestStatus
|
||||||
@@ -30,6 +32,7 @@ private const val POLL_INTERVAL_MS = 12_000L
|
|||||||
class RequestsViewModel @Inject constructor(
|
class RequestsViewModel @Inject constructor(
|
||||||
private val repository: RequestsRepository,
|
private val repository: RequestsRepository,
|
||||||
private val eventsStream: EventsStream,
|
private val eventsStream: EventsStream,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
private val internal = MutableStateFlow<UiState<List<RequestRef>>>(UiState.Loading)
|
||||||
@@ -45,6 +48,14 @@ class RequestsViewModel @Inject constructor(
|
|||||||
.collect { silentReload() }
|
.collect { silentReload() }
|
||||||
}
|
}
|
||||||
viewModelScope.launch { pollWhileInFlight() }
|
viewModelScope.launch { pollWhileInFlight() }
|
||||||
|
// Screen-level auto-recovery (issue #1245): a load that failed while
|
||||||
|
// the server was unreachable heals the moment health returns, without
|
||||||
|
// waiting for the next poll tick.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is UiState.Error) silentReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh(): Job = viewModelScope.launch {
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
@@ -71,7 +82,11 @@ class RequestsViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private suspend fun pollWhileInFlight() {
|
private suspend fun pollWhileInFlight() {
|
||||||
while (true) {
|
while (true) {
|
||||||
if (hasInFlightRequest()) {
|
// Also retry from a failed load (issue #1245): from UiState.Error
|
||||||
|
// the in-flight predicate can never become true, so without this
|
||||||
|
// the poll never self-heals the screen — e.g. after a transient
|
||||||
|
// 500 that health monitoring (transport-level) never saw.
|
||||||
|
if (hasInFlightRequest() || internal.value is UiState.Error) {
|
||||||
silentReload()
|
silentReload()
|
||||||
}
|
}
|
||||||
delay(POLL_INTERVAL_MS)
|
delay(POLL_INTERVAL_MS)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ import com.fabledsword.minstrel.nav.AlbumDetail
|
|||||||
import com.fabledsword.minstrel.nav.ArtistDetail
|
import com.fabledsword.minstrel.nav.ArtistDetail
|
||||||
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
import com.fabledsword.minstrel.nav.Search as SearchRoute
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
import com.fabledsword.minstrel.shared.widgets.TrackRow
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
import com.fabledsword.minstrel.shared.widgets.MainAppBarActions
|
||||||
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
import com.fabledsword.minstrel.shared.widgets.TrackCoverThumb
|
||||||
@@ -113,6 +114,7 @@ fun SearchScreen(
|
|||||||
onTrackPlay = viewModel::playTrack,
|
onTrackPlay = viewModel::playTrack,
|
||||||
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
onNavigateToAlbum = { id -> navController.navigate(AlbumDetail(id)) },
|
||||||
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
onNavigateToArtist = { id -> navController.navigate(ArtistDetail(id)) },
|
||||||
|
onRetry = viewModel::retry,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,11 +153,16 @@ private fun ResultsPane(
|
|||||||
onTrackPlay: (TrackRef) -> Unit,
|
onTrackPlay: (TrackRef) -> Unit,
|
||||||
onNavigateToAlbum: (String) -> Unit,
|
onNavigateToAlbum: (String) -> Unit,
|
||||||
onNavigateToArtist: (String) -> Unit,
|
onNavigateToArtist: (String) -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
) {
|
) {
|
||||||
when (state) {
|
when (state) {
|
||||||
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
SearchResultsState.Idle -> CenteredHint("Type to search your library.")
|
||||||
SearchResultsState.Loading -> LoadingCentered()
|
SearchResultsState.Loading -> LoadingCentered()
|
||||||
is SearchResultsState.Error -> CenteredHint("Search failed: ${state.message}")
|
is SearchResultsState.Error -> ErrorRetry(
|
||||||
|
title = "Search failed",
|
||||||
|
message = state.message,
|
||||||
|
onRetry = onRetry,
|
||||||
|
)
|
||||||
is SearchResultsState.Loaded -> {
|
is SearchResultsState.Loaded -> {
|
||||||
if (state.response.isEmpty) {
|
if (state.response.isEmpty) {
|
||||||
CenteredHint(
|
CenteredHint(
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.search.ui
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
import com.fabledsword.minstrel.models.SearchResponseRef
|
import com.fabledsword.minstrel.models.SearchResponseRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import com.fabledsword.minstrel.player.PlayerController
|
import com.fabledsword.minstrel.player.PlayerController
|
||||||
@@ -43,6 +45,7 @@ data class SearchState(
|
|||||||
class SearchViewModel @Inject constructor(
|
class SearchViewModel @Inject constructor(
|
||||||
private val repository: SearchRepository,
|
private val repository: SearchRepository,
|
||||||
private val player: PlayerController,
|
private val player: PlayerController,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val queryFlow = MutableStateFlow("")
|
private val queryFlow = MutableStateFlow("")
|
||||||
@@ -69,6 +72,14 @@ class SearchViewModel @Inject constructor(
|
|||||||
runSearch(q)
|
runSearch(q)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Screen-level auto-recovery (issue #1245): a search that failed
|
||||||
|
// while the server was unreachable re-runs when health returns —
|
||||||
|
// the queryFlow pipeline can't, since the text hasn't changed.
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value.results is SearchResultsState.Error) retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setQuery(value: String) {
|
fun setQuery(value: String) {
|
||||||
@@ -80,6 +91,18 @@ class SearchViewModel @Inject constructor(
|
|||||||
setQuery("")
|
setQuery("")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-run the current query after a failure. Bypasses the queryFlow
|
||||||
|
* pipeline, whose distinctUntilChanged drops a same-text resubmit —
|
||||||
|
* without this, a transient error pins "Search failed" until the
|
||||||
|
* user edits the query.
|
||||||
|
*/
|
||||||
|
fun retry() {
|
||||||
|
val q = internal.value.query.trim()
|
||||||
|
if (q.isEmpty()) return
|
||||||
|
viewModelScope.launch { runSearch(q) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tapping a search result builds a queue from the full visible
|
* Tapping a search result builds a queue from the full visible
|
||||||
* track-results list starting at the tapped entry, matching Flutter.
|
* track-results list starting at the tapped entry, matching Flutter.
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
|||||||
* ServerImage + fallback structure was identical at all three sites.
|
* ServerImage + fallback structure was identical at all three sites.
|
||||||
*
|
*
|
||||||
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
||||||
* (e.g. the `VariantPill` system-playlist label) — callers can use
|
* (e.g. the `VariantPill` system-playlist label, the play button) —
|
||||||
* `Modifier.align(...)` inside it.
|
* callers can use `Modifier.align(...)` inside it. The overlay sits on
|
||||||
|
* an outer, UN-clipped box so corner-anchored widgets are not cut off by
|
||||||
|
* [shape]: a `BottomEnd` play button on a `CircleShape` avatar falls in
|
||||||
|
* the square's corner, outside the circle, and would otherwise be clipped
|
||||||
|
* away. Only the artwork + background are clipped to [shape].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CoverTile(
|
fun CoverTile(
|
||||||
@@ -38,18 +42,23 @@ fun CoverTile(
|
|||||||
overlay: @Composable BoxScope.() -> Unit = {},
|
overlay: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier.size(size),
|
||||||
.size(size)
|
|
||||||
.clip(shape)
|
|
||||||
.background(background),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
ServerImage(
|
Box(
|
||||||
url = url,
|
modifier = Modifier
|
||||||
contentDescription = contentDescription,
|
.fillMaxSize()
|
||||||
modifier = Modifier.fillMaxSize(),
|
.clip(shape)
|
||||||
|
.background(background),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
fallback()
|
ServerImage(
|
||||||
|
url = url,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
overlay()
|
overlay()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -23,44 +24,63 @@ import com.fabledsword.minstrel.theme.LocalActionColors
|
|||||||
* Shared error-with-retry widget. Surfaces a brief message and a
|
* Shared error-with-retry widget. Surfaces a brief message and a
|
||||||
* primary action button. Per the design system, the button uses
|
* primary action button. Per the design system, the button uses
|
||||||
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
* `LocalActionColors.primary` (Moss) — NEVER the accent.
|
||||||
|
*
|
||||||
|
* Renders inside a single-item LazyColumn (same rationale as
|
||||||
|
* [EmptyState]) so the widget participates in nested-scroll dispatch
|
||||||
|
* under PullToRefreshBox — both the Retry button and a pull gesture
|
||||||
|
* can recover from the error state.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun ErrorRetry(
|
fun ErrorRetry(
|
||||||
message: String,
|
message: String,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
title: String? = null,
|
||||||
retryLabel: String = "Retry",
|
retryLabel: String = "Retry",
|
||||||
) {
|
) {
|
||||||
val actionColors = LocalActionColors.current
|
val actionColors = LocalActionColors.current
|
||||||
Column(
|
LazyColumn(modifier = modifier.fillMaxSize()) {
|
||||||
modifier = modifier
|
item {
|
||||||
.fillMaxSize()
|
Column(
|
||||||
.padding(32.dp),
|
modifier = Modifier
|
||||||
verticalArrangement = Arrangement.Center,
|
.fillParentMaxSize()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
.padding(32.dp),
|
||||||
) {
|
verticalArrangement = Arrangement.Center,
|
||||||
Icon(
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
imageVector = Lucide.TriangleAlert,
|
) {
|
||||||
contentDescription = null,
|
Icon(
|
||||||
modifier = Modifier.size(48.dp),
|
imageVector = Lucide.TriangleAlert,
|
||||||
tint = MaterialTheme.colorScheme.error,
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.size(48.dp),
|
||||||
Text(
|
tint = MaterialTheme.colorScheme.error,
|
||||||
text = message,
|
)
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
if (title != null) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
text = title,
|
||||||
textAlign = TextAlign.Center,
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
)
|
style = MaterialTheme.typography.titleMedium,
|
||||||
Button(
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
onClick = onRetry,
|
textAlign = TextAlign.Center,
|
||||||
modifier = Modifier.padding(top = 16.dp),
|
)
|
||||||
colors = ButtonDefaults.buttonColors(
|
}
|
||||||
containerColor = actionColors.primary,
|
Text(
|
||||||
contentColor = actionColors.onAction,
|
text = message,
|
||||||
),
|
modifier = Modifier.padding(top = if (title != null) 8.dp else 16.dp),
|
||||||
) {
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
Text(retryLabel)
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = onRetry,
|
||||||
|
modifier = Modifier.padding(top = 16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = actionColors.primary,
|
||||||
|
contentColor = actionColors.onAction,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(retryLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,51 @@ class BuildPlaylistsRowTest {
|
|||||||
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
||||||
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun songsLike(id: String, cached: Boolean) = PlaylistRef(
|
||||||
|
id = id,
|
||||||
|
userId = "u",
|
||||||
|
name = "Songs like $id",
|
||||||
|
systemVariant = "songs_like_artist",
|
||||||
|
trackCount = 25,
|
||||||
|
fullyCached = cached,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `songs-like no longer appears in the main playlists carousel`() {
|
||||||
|
val owned = listOf(songsLike("a", cached = true), user("u1", cached = true))
|
||||||
|
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
assertTrue("a" !in reals, "songs-like mix leaked into the Playlists row: $reals")
|
||||||
|
assertTrue("u1" in reals)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `online songs-like row shows every generated mix uncapped`() {
|
||||||
|
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
|
||||||
|
val owned = (1..6).map { songsLike("a$it", cached = true) }
|
||||||
|
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
assertEquals((1..6).map { "a$it" }, reals)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `online songs-like row shows placeholders when none generated`() {
|
||||||
|
val row = buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = false)
|
||||||
|
assertTrue(row.isNotEmpty())
|
||||||
|
assertTrue(row.all { it is PlaylistRowItem.Placeholder })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `offline songs-like row shows cached mixes available-first, none hides it`() {
|
||||||
|
val owned = listOf(songsLike("partial", cached = false), songsLike("full", cached = true))
|
||||||
|
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = true)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
// Fully-cached songs-like mix (not refreshable) is available; the
|
||||||
|
// un-cached one greys and sorts after. No placeholders offline.
|
||||||
|
assertEquals(listOf("full", "partial"), reals)
|
||||||
|
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
|
||||||
|
|
||||||
|
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-4
@@ -11,6 +11,7 @@ import com.fabledsword.minstrel.testutil.MainDispatcherExtension
|
|||||||
import io.mockk.every
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.flow
|
import kotlinx.coroutines.flow.flow
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -21,6 +22,13 @@ import kotlin.test.assertEquals
|
|||||||
@ExtendWith(MainDispatcherExtension::class)
|
@ExtendWith(MainDispatcherExtension::class)
|
||||||
class LibraryViewModelTest {
|
class LibraryViewModelTest {
|
||||||
|
|
||||||
|
// lastSyncError needs a real StateFlow: the VM combines it into uiState,
|
||||||
|
// and a relaxed-mock flow never emits, so the combine would never fire.
|
||||||
|
private fun syncControllerMock(): SyncController =
|
||||||
|
mockk<SyncController>(relaxed = true) {
|
||||||
|
every { lastSyncError } returns MutableStateFlow(null)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `initial state is Loading before any DAO emission`() = runTest {
|
fun `initial state is Loading before any DAO emission`() = runTest {
|
||||||
val repo = mockk<LibraryRepository>()
|
val repo = mockk<LibraryRepository>()
|
||||||
@@ -29,7 +37,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +59,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,7 +79,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,7 +102,7 @@ class LibraryViewModelTest {
|
|||||||
|
|
||||||
val vm = LibraryViewModel(
|
val vm = LibraryViewModel(
|
||||||
repo,
|
repo,
|
||||||
mockk<SyncController>(relaxed = true),
|
syncControllerMock(),
|
||||||
mockk<PlayerController>(relaxed = true),
|
mockk<PlayerController>(relaxed = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/server"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -146,6 +148,16 @@ func run() error {
|
|||||||
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings)
|
||||||
coverEnricher.DataDir = cfg.Storage.DataDir
|
coverEnricher.DataDir = cfg.Storage.DataDir
|
||||||
|
|
||||||
|
// Recommendation tuning lab (#1250): seeds shipped defaults on first
|
||||||
|
// boot and pushes the daily_mix weights + taste config into package
|
||||||
|
// playlists — must precede the scheduler so the first builds score
|
||||||
|
// with the operator's tuned values, not the pre-push literals.
|
||||||
|
recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("recommendation settings service init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
// One unified scan chain: library walk → MBID backfill → cover enrich.
|
||||||
// Boot-time scan and manual-trigger scans share this path; results land
|
// Boot-time scan and manual-trigger scans share this path; results land
|
||||||
// in scan_runs for the admin overview.
|
// in scan_runs for the admin overview.
|
||||||
@@ -194,6 +206,25 @@ func run() error {
|
|||||||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||||||
go similarityWorker.Run(ctx)
|
go similarityWorker.Run(ctx)
|
||||||
|
|
||||||
|
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
||||||
|
// tag providers with tag_provider_settings, bumps the sources version if
|
||||||
|
// the provider set changed (re-opening settled rows), then drains tracks
|
||||||
|
// needing folksonomy tags on a periodic tick. Standalone (not in the file
|
||||||
|
// scan chain) because tag lookups need only DB fields — MBID / artist /
|
||||||
|
// title — that a scan has already imported.
|
||||||
|
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("tag settings service init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
|
||||||
|
logger.Warn("tags: provider-hash boot check failed", "err", berr)
|
||||||
|
} else if bumped {
|
||||||
|
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
|
||||||
|
}
|
||||||
|
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
|
||||||
|
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
|
||||||
|
|
||||||
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
||||||
// that have no writer-side close path or retention policy:
|
// that have no writer-side close path or retention policy:
|
||||||
// orphan play_events, stale play_sessions, expired
|
// orphan play_events, stale play_sessions, expired
|
||||||
@@ -294,6 +325,8 @@ func run() error {
|
|||||||
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
}, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg)
|
||||||
srv.Bus = bus
|
srv.Bus = bus
|
||||||
srv.PlaylistScheduler = playlistScheduler
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
|
srv.RecSettings = recSettings
|
||||||
|
srv.TagSettings = tagSettings
|
||||||
srv.StreamSecret = cfg.StreamSecret
|
srv.StreamSecret = cfg.StreamSecret
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Admin recommendation-trends endpoint (#1251): weekly per-surface
|
||||||
|
// outcome series with tuning-audit markers — the verify half of the
|
||||||
|
// tune→verify loop the tuning lab (#1250) opens. Aggregated across
|
||||||
|
// all users because the knobs are global; rows carry rates only.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
trendsDefaultWeeks = 12
|
||||||
|
trendsMaxWeeks = 52
|
||||||
|
// trendsAuditFetchCap bounds the audit fetch; markers older than
|
||||||
|
// the window are dropped in Go. Far above any real knob-turn count
|
||||||
|
// inside a year.
|
||||||
|
trendsAuditFetchCap = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
// trendPoint is one week of one surface family's outcomes.
|
||||||
|
type trendPoint struct {
|
||||||
|
WeekStart string `json:"week_start"` // ISO date (Monday)
|
||||||
|
Plays int64 `json:"plays"`
|
||||||
|
Skips int64 `json:"skips"`
|
||||||
|
SkipRate float64 `json:"skip_rate"`
|
||||||
|
AvgCompletion float64 `json:"avg_completion"`
|
||||||
|
// TasteHitRate is the share of plays whose track's artist carries a
|
||||||
|
// positive weight in the player's current taste profile — a drifted
|
||||||
|
// but retroactive read on whether the surface feeds taste-fitting
|
||||||
|
// tracks.
|
||||||
|
TasteHitRate float64 `json:"taste_hit_rate"`
|
||||||
|
// completionN carries the completion sample count through same-week
|
||||||
|
// merges so avg_completion stays sample-weighted; not serialized.
|
||||||
|
completionN int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// trendSeries is one surface family's weekly series.
|
||||||
|
type trendSeries struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Intent string `json:"intent"` // go_to | discovery | direct; "" for the manual baseline
|
||||||
|
Plays int64 `json:"plays"` // window total, for sorting/volume-gating
|
||||||
|
Points []trendPoint `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// trendMarker is one tuning-audit event annotated on the timeline.
|
||||||
|
type trendMarker struct {
|
||||||
|
ChangedAt string `json:"changed_at"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Changes json.RawMessage `json:"changes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type trendsResp struct {
|
||||||
|
Weeks int `json:"weeks"`
|
||||||
|
Series []trendSeries `json:"series"`
|
||||||
|
Markers []trendMarker `json:"markers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationTrends implements
|
||||||
|
// GET /api/admin/recommendation-trends?weeks=N (default 12, cap 52).
|
||||||
|
func (h *handlers) handleGetRecommendationTrends(w http.ResponseWriter, r *http.Request) {
|
||||||
|
weeks := trendsDefaultWeeks
|
||||||
|
if v := r.URL.Query().Get("weeks"); v != "" {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid weeks"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > trendsMaxWeeks {
|
||||||
|
n = trendsMaxWeeks
|
||||||
|
}
|
||||||
|
weeks = n
|
||||||
|
}
|
||||||
|
|
||||||
|
q := dbq.New(h.pool)
|
||||||
|
rows, err := q.RecommendationWeeklyTrends(r.Context(), int32(weeks))
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: recommendation trends", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
audits, err := q.ListTuningAudit(r.Context(), trendsAuditFetchCap)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: recommendation trends audit", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, buildTrendsResponse(weeks, time.Now().UTC(), rows, audits))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTrendsResponse folds weekly rows into per-family series and
|
||||||
|
// windows the audit markers. Split from the handler for pure-unit
|
||||||
|
// testability.
|
||||||
|
func buildTrendsResponse(
|
||||||
|
weeks int, now time.Time,
|
||||||
|
rows []dbq.RecommendationWeeklyTrendsRow,
|
||||||
|
audits []dbq.RecommendationTuningAudit,
|
||||||
|
) trendsResp {
|
||||||
|
type accum struct {
|
||||||
|
fam recFamily
|
||||||
|
plays int64
|
||||||
|
points []trendPoint
|
||||||
|
}
|
||||||
|
families := map[string]*accum{}
|
||||||
|
for _, row := range rows {
|
||||||
|
fam := recFamily{key: "manual", label: "Manual library plays"}
|
||||||
|
if row.Source != nil && *row.Source != "" {
|
||||||
|
fam = bucketRecSource(*row.Source)
|
||||||
|
}
|
||||||
|
acc, ok := families[fam.key]
|
||||||
|
if !ok {
|
||||||
|
acc = &accum{fam: fam}
|
||||||
|
families[fam.key] = acc
|
||||||
|
}
|
||||||
|
acc.plays += row.Plays
|
||||||
|
p := trendPoint{
|
||||||
|
WeekStart: row.WeekStart.Time.Format("2006-01-02"),
|
||||||
|
Plays: row.Plays,
|
||||||
|
Skips: row.Skips,
|
||||||
|
AvgCompletion: row.AvgCompletion,
|
||||||
|
completionN: row.CompletionN,
|
||||||
|
}
|
||||||
|
if row.Plays > 0 {
|
||||||
|
p.SkipRate = float64(row.Skips) / float64(row.Plays)
|
||||||
|
p.TasteHitRate = float64(row.TasteHits) / float64(row.Plays)
|
||||||
|
}
|
||||||
|
// Same family can arrive as several raw sources (radio:<uuid>);
|
||||||
|
// merge same-week points play-weighted.
|
||||||
|
if n := len(acc.points); n > 0 && acc.points[n-1].WeekStart == p.WeekStart {
|
||||||
|
acc.points[n-1] = mergeTrendPoints(acc.points[n-1], p)
|
||||||
|
} else {
|
||||||
|
acc.points = append(acc.points, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := trendsResp{Weeks: weeks, Series: []trendSeries{}, Markers: []trendMarker{}}
|
||||||
|
for _, acc := range families {
|
||||||
|
resp.Series = append(resp.Series, trendSeries{
|
||||||
|
Key: acc.fam.key,
|
||||||
|
Label: acc.fam.label,
|
||||||
|
Intent: acc.fam.intent,
|
||||||
|
Plays: acc.plays,
|
||||||
|
Points: acc.points,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(resp.Series, func(i, j int) bool {
|
||||||
|
if resp.Series[i].Plays != resp.Series[j].Plays {
|
||||||
|
return resp.Series[i].Plays > resp.Series[j].Plays
|
||||||
|
}
|
||||||
|
return resp.Series[i].Key < resp.Series[j].Key
|
||||||
|
})
|
||||||
|
|
||||||
|
cutoff := now.Add(-time.Duration(weeks) * 7 * 24 * time.Hour)
|
||||||
|
for _, a := range audits {
|
||||||
|
if a.ChangedAt.Time.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp.Markers = append(resp.Markers, trendMarker{
|
||||||
|
ChangedAt: a.ChangedAt.Time.UTC().Format(time.RFC3339),
|
||||||
|
Scope: a.Scope,
|
||||||
|
Action: a.Action,
|
||||||
|
Changes: json.RawMessage(a.Changes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// ListTuningAudit returns newest-first; the timeline reads better
|
||||||
|
// oldest-first.
|
||||||
|
sort.Slice(resp.Markers, func(i, j int) bool {
|
||||||
|
return resp.Markers[i].ChangedAt < resp.Markers[j].ChangedAt
|
||||||
|
})
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeTrendPoints combines two same-week points of one family:
|
||||||
|
// counts add, skip/taste rates re-derive from the merged counts, and
|
||||||
|
// avg_completion is weighted by each side's completion sample count.
|
||||||
|
func mergeTrendPoints(a, b trendPoint) trendPoint {
|
||||||
|
out := trendPoint{
|
||||||
|
WeekStart: a.WeekStart,
|
||||||
|
Plays: a.Plays + b.Plays,
|
||||||
|
Skips: a.Skips + b.Skips,
|
||||||
|
completionN: a.completionN + b.completionN,
|
||||||
|
}
|
||||||
|
if out.Plays > 0 {
|
||||||
|
out.SkipRate = float64(out.Skips) / float64(out.Plays)
|
||||||
|
out.TasteHitRate = (a.TasteHitRate*float64(a.Plays) + b.TasteHitRate*float64(b.Plays)) /
|
||||||
|
float64(out.Plays)
|
||||||
|
}
|
||||||
|
if out.completionN > 0 {
|
||||||
|
out.AvgCompletion = (a.AvgCompletion*float64(a.completionN) + b.AvgCompletion*float64(b.completionN)) /
|
||||||
|
float64(out.completionN)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func date(s string) pgtype.Date {
|
||||||
|
t, _ := time.Parse("2006-01-02", s)
|
||||||
|
return pgtype.Date{Time: t, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildTrendsResponse_SeriesMergingAndMarkers(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC)
|
||||||
|
rows := []dbq.RecommendationWeeklyTrendsRow{
|
||||||
|
// Two radio session sources in the same week collapse into one
|
||||||
|
// family point; completion weighting by sample count.
|
||||||
|
{WeekStart: date("2026-06-22"), Source: src("radio:aaaa"),
|
||||||
|
Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 0.6, TasteHits: 3},
|
||||||
|
{WeekStart: date("2026-06-22"), Source: src("radio:bbbb"),
|
||||||
|
Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2, TasteHits: 0},
|
||||||
|
{WeekStart: date("2026-06-29"), Source: src("radio:aaaa"),
|
||||||
|
Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9, TasteHits: 1},
|
||||||
|
// NULL source = manual baseline family.
|
||||||
|
{WeekStart: date("2026-06-29"), Source: nil,
|
||||||
|
Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.8, TasteHits: 4},
|
||||||
|
}
|
||||||
|
audits := []dbq.RecommendationTuningAudit{
|
||||||
|
{ID: 2, ChangedAt: pgtype.Timestamptz{Time: now.Add(-24 * time.Hour), Valid: true},
|
||||||
|
Scope: "radio", Action: "update", Changes: []byte(`[{"field":"taste_weight","old":1,"new":2}]`)},
|
||||||
|
// Older than the window → dropped.
|
||||||
|
{ID: 1, ChangedAt: pgtype.Timestamptz{Time: now.Add(-100 * 7 * 24 * time.Hour), Valid: true},
|
||||||
|
Scope: "taste", Action: "reset", Changes: []byte(`[]`)},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildTrendsResponse(12, now, rows, audits)
|
||||||
|
|
||||||
|
if len(resp.Series) != 2 {
|
||||||
|
t.Fatalf("series = %d, want 2 (radio + manual)", len(resp.Series))
|
||||||
|
}
|
||||||
|
radio := resp.Series[0] // 6 plays > manual's 5 → sorted first
|
||||||
|
if radio.Key != "radio" || radio.Plays != 6 {
|
||||||
|
t.Fatalf("series[0] = %s/%d, want radio/6", radio.Key, radio.Plays)
|
||||||
|
}
|
||||||
|
if len(radio.Points) != 2 {
|
||||||
|
t.Fatalf("radio points = %d, want 2 weeks", len(radio.Points))
|
||||||
|
}
|
||||||
|
wk1 := radio.Points[0]
|
||||||
|
if wk1.WeekStart != "2026-06-22" || wk1.Plays != 4 || wk1.Skips != 2 {
|
||||||
|
t.Errorf("week1 = %+v, want 2026-06-22 with 4 plays / 2 skips", wk1)
|
||||||
|
}
|
||||||
|
if wk1.SkipRate != 0.5 {
|
||||||
|
t.Errorf("week1 skip_rate = %v, want 0.5", wk1.SkipRate)
|
||||||
|
}
|
||||||
|
// Completion weighted by sample count: (0.6*3 + 0.2*1) / 4 = 0.5.
|
||||||
|
if wk1.AvgCompletion < 0.49 || wk1.AvgCompletion > 0.51 {
|
||||||
|
t.Errorf("week1 avg_completion = %v, want 0.5", wk1.AvgCompletion)
|
||||||
|
}
|
||||||
|
// Taste hits: 3 of 4 plays.
|
||||||
|
if wk1.TasteHitRate != 0.75 {
|
||||||
|
t.Errorf("week1 taste_hit_rate = %v, want 0.75", wk1.TasteHitRate)
|
||||||
|
}
|
||||||
|
if manual := resp.Series[1]; manual.Key != "manual" || manual.Intent != "" {
|
||||||
|
t.Errorf("series[1] = %+v, want the manual baseline family", manual)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Markers) != 1 {
|
||||||
|
t.Fatalf("markers = %d, want 1 (out-of-window marker dropped)", len(resp.Markers))
|
||||||
|
}
|
||||||
|
if resp.Markers[0].Scope != "radio" || resp.Markers[0].Action != "update" {
|
||||||
|
t.Errorf("marker = %+v, want radio/update", resp.Markers[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTrendsRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/admin/recommendation-trends", h.handleGetRecommendationTrends)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTrends_EndToEnd(t *testing.T) {
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
user := seedUser(t, pool, "trends", "pw", true)
|
||||||
|
artist := seedArtist(t, pool, "TrendArtist")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "TrendAlbum", 2020)
|
||||||
|
tk := seedTrack(t, pool, album.ID, artist.ID, "TrendTrack", 1, 200000)
|
||||||
|
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||||
|
|
||||||
|
// Positive taste weight for the artist → plays count as taste hits.
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 1.5)`,
|
||||||
|
user.ID, artist.ID); err != nil {
|
||||||
|
t.Fatalf("seed taste profile: %v", err)
|
||||||
|
}
|
||||||
|
radio := "radio"
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radio, nil, 0.9, false)
|
||||||
|
// A knob turn to mark the timeline.
|
||||||
|
if err := h.recSettings.UpdateProfile(context.Background(), "radio",
|
||||||
|
map[string]float64{"taste_weight": 2}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTrendsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp trendsResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Weeks != trendsDefaultWeeks {
|
||||||
|
t.Errorf("weeks = %d, want %d", resp.Weeks, trendsDefaultWeeks)
|
||||||
|
}
|
||||||
|
var radioSeries *trendSeries
|
||||||
|
for i := range resp.Series {
|
||||||
|
if resp.Series[i].Key == "radio" {
|
||||||
|
radioSeries = &resp.Series[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if radioSeries == nil || len(radioSeries.Points) != 1 {
|
||||||
|
t.Fatalf("radio series = %+v, want one point", radioSeries)
|
||||||
|
}
|
||||||
|
if radioSeries.Points[0].TasteHitRate != 1.0 {
|
||||||
|
t.Errorf("taste_hit_rate = %v, want 1.0 (positive-weight artist)",
|
||||||
|
radioSeries.Points[0].TasteHitRate)
|
||||||
|
}
|
||||||
|
if len(resp.Markers) != 1 || resp.Markers[0].Scope != "radio" {
|
||||||
|
t.Errorf("markers = %+v, want the radio knob turn", resp.Markers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTrends_InvalidWeeks(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-trends?weeks=zero", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTrendsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status = %d, want 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
// Admin recommendation-tuning endpoints (#1250): the defaults-
|
||||||
|
// discovery lab. GET returns current values + shipped defaults for
|
||||||
|
// every scope; PATCH applies a partial update to one scope; reset
|
||||||
|
// restores a scope to shipped defaults. Every change writes an audit
|
||||||
|
// row (consumed by the metrics trend view, #1251).
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// weightsResp is one weight profile on the wire, keyed by the same
|
||||||
|
// snake_case field names the PATCH body accepts.
|
||||||
|
type weightsResp struct {
|
||||||
|
BaseWeight float64 `json:"base_weight"`
|
||||||
|
LikeBoost float64 `json:"like_boost"`
|
||||||
|
RecencyWeight float64 `json:"recency_weight"`
|
||||||
|
SkipPenalty float64 `json:"skip_penalty"`
|
||||||
|
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||||
|
ContextWeight float64 `json:"context_weight"`
|
||||||
|
SimilarityWeight float64 `json:"similarity_weight"`
|
||||||
|
TasteWeight float64 `json:"taste_weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
||||||
|
return weightsResp{
|
||||||
|
BaseWeight: w.BaseWeight,
|
||||||
|
LikeBoost: w.LikeBoost,
|
||||||
|
RecencyWeight: w.RecencyWeight,
|
||||||
|
SkipPenalty: w.SkipPenalty,
|
||||||
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
|
ContextWeight: w.ContextWeight,
|
||||||
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
|
TasteWeight: w.TasteWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tasteTuningResp struct {
|
||||||
|
HalfLifeDays float64 `json:"half_life_days"`
|
||||||
|
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||||
|
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||||
|
EngagementFull float64 `json:"engagement_full"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||||
|
return tasteTuningResp{
|
||||||
|
HalfLifeDays: t.HalfLifeDays,
|
||||||
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
|
EngagementFull: t.EngagementFull,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tuningSnapshot is both the GET response and the post-mutation echo:
|
||||||
|
// current values alongside shipped defaults so the card can mark
|
||||||
|
// which knobs deviate.
|
||||||
|
type tuningSnapshot struct {
|
||||||
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
Shipped struct {
|
||||||
|
Profiles map[string]weightsResp `json:"profiles"`
|
||||||
|
Taste tasteTuningResp `json:"taste"`
|
||||||
|
} `json:"shipped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) tuningSnapshot() tuningSnapshot {
|
||||||
|
var out tuningSnapshot
|
||||||
|
out.Profiles = map[string]weightsResp{
|
||||||
|
recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)),
|
||||||
|
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
|
||||||
|
}
|
||||||
|
out.Taste = tasteRespFrom(h.recSettings.Taste())
|
||||||
|
out.Shipped.Profiles = map[string]weightsResp{
|
||||||
|
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
|
||||||
|
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
|
||||||
|
}
|
||||||
|
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning.
|
||||||
|
func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// patchTuningReq carries the partial update: field name → new value,
|
||||||
|
// using the same snake_case names the GET response emits.
|
||||||
|
type patchTuningReq struct {
|
||||||
|
Values map[string]float64 `json:"values"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePatchRecommendationTuning implements
|
||||||
|
// PATCH /api/admin/recommendation-tuning/{scope}.
|
||||||
|
func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := chi.URLParam(r, "scope")
|
||||||
|
var body patchTuningReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(body.Values) == 0 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "values is empty"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if scope == recsettings.ScopeTaste {
|
||||||
|
err = h.recSettings.UpdateTaste(r.Context(), body.Values)
|
||||||
|
} else {
|
||||||
|
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeTuningErr(w, h, scope, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResetRecommendationTuning implements
|
||||||
|
// POST /api/admin/recommendation-tuning/{scope}/reset.
|
||||||
|
func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scope := chi.URLParam(r, "scope")
|
||||||
|
if err := h.recSettings.Reset(r.Context(), scope); err != nil {
|
||||||
|
writeTuningErr(w, h, scope, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, h.tuningSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTuningErr maps recsettings validation errors to 400s and
|
||||||
|
// everything else to a logged 500.
|
||||||
|
func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, recsettings.ErrUnknownScope):
|
||||||
|
writeErr(w, &apierror.Error{
|
||||||
|
Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope",
|
||||||
|
})
|
||||||
|
case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange):
|
||||||
|
writeErr(w, apierror.BadRequest("invalid_tuning", err.Error()))
|
||||||
|
default:
|
||||||
|
h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("tuning update failed", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTuningRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||||
|
r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||||
|
r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot {
|
||||||
|
t.Helper()
|
||||||
|
var snap tuningSnapshot
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newTuningRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
snap := decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 {
|
||||||
|
t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles)
|
||||||
|
}
|
||||||
|
if snap.Taste.HalfLifeDays != 75 {
|
||||||
|
t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays)
|
||||||
|
}
|
||||||
|
if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] {
|
||||||
|
t.Error("untouched values must equal shipped defaults")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_PatchAndReset(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
r := newTuningRouter(h)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio",
|
||||||
|
strings.NewReader(`{"values":{"taste_weight": 2.5}}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
snap := decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 2.5 {
|
||||||
|
t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight)
|
||||||
|
}
|
||||||
|
// The change is live for the radio scoring path.
|
||||||
|
if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 {
|
||||||
|
t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil)
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("reset status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
snap = decodeTuning(t, rec)
|
||||||
|
if snap.Profiles["radio"].TasteWeight != 1.0 {
|
||||||
|
t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationTuning_PatchErrors(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
r := newTuningRouter(h)
|
||||||
|
cases := []struct {
|
||||||
|
name, path, body string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"unknown scope", "/api/admin/recommendation-tuning/banana",
|
||||||
|
`{"values":{"taste_weight":1}}`, http.StatusNotFound},
|
||||||
|
{"unknown field", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{"values":{"vibes":1}}`, http.StatusBadRequest},
|
||||||
|
{"out of range", "/api/admin/recommendation-tuning/taste",
|
||||||
|
`{"values":{"engagement_neutral":2}}`, http.StatusBadRequest},
|
||||||
|
{"empty values", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{"values":{}}`, http.StatusBadRequest},
|
||||||
|
{"bad json", "/api/admin/recommendation-tuning/radio",
|
||||||
|
`{`, http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != c.want {
|
||||||
|
t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagSourceResp is the wire shape for one tag-enrichment provider in the
|
||||||
|
// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface
|
||||||
|
// (admin_cover_sources.go) — a separate, independent settings card so a new
|
||||||
|
// tag source is added without touching art settings (#1490).
|
||||||
|
type tagSourceResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
RequiresAPIKey bool `json:"requires_api_key"`
|
||||||
|
Supports []string `json:"supports"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
APIKeySet bool `json:"api_key_set"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
|
Testable bool `json:"testable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagSourceRespFrom(info tags.ProviderInfo) tagSourceResp {
|
||||||
|
supports := info.Supports
|
||||||
|
if supports == nil {
|
||||||
|
supports = []string{}
|
||||||
|
}
|
||||||
|
return tagSourceResp{
|
||||||
|
ID: info.ID,
|
||||||
|
DisplayName: info.DisplayName,
|
||||||
|
RequiresAPIKey: info.RequiresAPIKey,
|
||||||
|
Supports: supports,
|
||||||
|
Enabled: info.Enabled,
|
||||||
|
APIKeySet: info.APIKeySet,
|
||||||
|
DisplayOrder: info.DisplayOrder,
|
||||||
|
Testable: info.Testable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagSourcesListResp struct {
|
||||||
|
Providers []tagSourceResp `json:"providers"`
|
||||||
|
SourcesVersion int32 `json:"sources_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListTagSources implements GET /api/admin/tag-sources.
|
||||||
|
func (h *handlers) handleListTagSources(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
infos := h.tagSettings.ListProviderInfo()
|
||||||
|
out := tagSourcesListResp{
|
||||||
|
Providers: make([]tagSourceResp, 0, len(infos)),
|
||||||
|
SourcesVersion: h.tagSettings.CurrentVersion(),
|
||||||
|
}
|
||||||
|
for _, info := range infos {
|
||||||
|
out.Providers = append(out.Providers, tagSourceRespFrom(info))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTagSourceReq is the PATCH body. nil pointers mean "leave
|
||||||
|
// unchanged"; non-nil sets (empty api_key clears).
|
||||||
|
type updateTagSourceReq struct {
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
APIKey *string `json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateTagSourceResp struct {
|
||||||
|
tagSourceResp
|
||||||
|
VersionBumped bool `json:"version_bumped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateTagSource implements PATCH /api/admin/tag-sources/{provider_id}.
|
||||||
|
func (h *handlers) handleUpdateTagSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "provider_id")
|
||||||
|
|
||||||
|
var body updateTagSourceReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bumped, err := h.tagSettings.UpdateProvider(r.Context(), id, tags.ProviderUpdatePatch{
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
APIKey: body.APIKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, tags.ErrProviderNotFound) {
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin: update tag source", "id", id, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("update failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var found tagSourceResp
|
||||||
|
for _, info := range h.tagSettings.ListProviderInfo() {
|
||||||
|
if info.ID == id {
|
||||||
|
found = tagSourceRespFrom(info)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, updateTagSourceResp{tagSourceResp: found, VersionBumped: bumped})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testTagSourceResp struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test.
|
||||||
|
func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "provider_id")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := h.tagSettings.TestProvider(r.Context(), id)
|
||||||
|
duration := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
writeJSON(w, http.StatusOK, testTagSourceResp{OK: true, DurationMs: duration})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, tags.ErrProviderNotFound) {
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not testable, or the test failed — both surface as ok=false with a
|
||||||
|
// 200 (the operation completed; the result is data, not an error).
|
||||||
|
writeJSON(w, http.StatusOK, testTagSourceResp{OK: false, DurationMs: duration, Error: err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type researchTagsResp struct {
|
||||||
|
SourcesVersion int32 `json:"sources_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResearchTags implements POST /api/admin/tag-sources/research: bump
|
||||||
|
// the sources version so every settled ('none') track becomes eligible for
|
||||||
|
// a re-enrichment pass, then the background worker re-processes them.
|
||||||
|
func (h *handlers) handleResearchTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
newVer, err := h.tagSettings.BumpVersion(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: research tags", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("research failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, researchTagsResp{SourcesVersion: newVer})
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apiTestTagProvider is a minimal TrackTagProvider for api-package tests.
|
||||||
|
// Does not implement TestableProvider.
|
||||||
|
type apiTestTagProvider struct {
|
||||||
|
id string
|
||||||
|
display string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiTestTagProvider) ID() string { return p.id }
|
||||||
|
func (p *apiTestTagProvider) DisplayName() string { return p.display }
|
||||||
|
func (p *apiTestTagProvider) RequiresAPIKey() bool { return false }
|
||||||
|
func (p *apiTestTagProvider) DefaultEnabled() bool { return true }
|
||||||
|
func (p *apiTestTagProvider) Configure(_ tags.ProviderSettings) error { return nil }
|
||||||
|
func (p *apiTestTagProvider) FetchTrackTags(_ context.Context, _ tags.TrackRef) ([]tags.Tag, error) {
|
||||||
|
return []tags.Tag{{Name: "x", Weight: 1}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiTestTestableTagProvider also implements TestableProvider.
|
||||||
|
type apiTestTestableTagProvider struct {
|
||||||
|
apiTestTagProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiTestTestableTagProvider) TestConnection(_ context.Context) error { return nil }
|
||||||
|
|
||||||
|
func newAdminTagSourcesRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/api/admin", func(admin chi.Router) {
|
||||||
|
admin.Use(auth.RequireAdmin())
|
||||||
|
admin.Get("/tag-sources", h.handleListTagSources)
|
||||||
|
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
||||||
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// testHandlersWithTagSettings installs a tags.SettingsService backed by the
|
||||||
|
// test DB. Register the needed fake providers BEFORE calling this, and pair
|
||||||
|
// with tags.ResetRegistryForTests in t.Cleanup.
|
||||||
|
func testHandlersWithTagSettings(t *testing.T) *handlers {
|
||||||
|
t.Helper()
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
s, err := tags.NewSettingsService(context.Background(), pool, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService: %v", err)
|
||||||
|
}
|
||||||
|
h.tagSettings = s
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminListTagSources_ReturnsRegisteredProviders(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "test-tag-prov", display: "Test Tag Provider"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tslist", "pw", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/tag-sources", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp tagSourcesListResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Providers) != 1 || resp.Providers[0].ID != "test-tag-prov" {
|
||||||
|
t.Fatalf("providers = %+v, want 1 (test-tag-prov)", resp.Providers)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, s := range resp.Providers[0].Supports {
|
||||||
|
if s == "track_tags" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("supports = %v, want to include track_tags", resp.Providers[0].Supports)
|
||||||
|
}
|
||||||
|
if resp.Providers[0].Testable {
|
||||||
|
t.Error("testable = true, want false (no TestConnection)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_FlippingEnabledBumpsVersion(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "flip-tag", display: "Flip Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tsflip", "pw", true)
|
||||||
|
|
||||||
|
disabled := false
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{Enabled: &disabled})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/flip-tag", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp updateTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.VersionBumped {
|
||||||
|
t.Error("version_bumped = false, want true after flipping enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_KeyOnlyDoesNotBump(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "key-tag", display: "Key Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tskey", "pw", true)
|
||||||
|
|
||||||
|
newKey := "newkey"
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{APIKey: &newKey})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/key-tag", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp updateTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.VersionBumped {
|
||||||
|
t.Error("version_bumped = true, want false for key-only change")
|
||||||
|
}
|
||||||
|
if !resp.APIKeySet {
|
||||||
|
t.Error("api_key_set = false, want true after setting key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_UnknownProvider404(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "ts404", "pw", true)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/missing", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestTagSource_NonAdminReturns403(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTestableTagProvider{apiTestTagProvider{id: "testable-tag", display: "Testable Tag"}})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
nonAdmin := seedUser(t, h.pool, "tsnoadmin", "pw", false)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/testable-tag/test", nil)
|
||||||
|
req = withUser(req, nonAdmin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestTagSource_NotTestableReturnsOkFalse(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "nontestable-tag", display: "Non-Testable Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tsntest", "pw", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/nontestable-tag/test", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp testTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.OK {
|
||||||
|
t.Error("ok = true, want false for non-testable provider")
|
||||||
|
}
|
||||||
|
if resp.Error == "" {
|
||||||
|
t.Error("error string empty, want a message")
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-1
@@ -22,16 +22,19 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, tagSettings *tags.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) {
|
||||||
rng := rand.New(rand.NewSource(rand.Int63()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
|
recSettings: recSettings,
|
||||||
rng: rng.Float64,
|
rng: rng.Float64,
|
||||||
lidarrCfg: lidarrCfg,
|
lidarrCfg: lidarrCfg,
|
||||||
lidarrRequests: lidarrReqs,
|
lidarrRequests: lidarrReqs,
|
||||||
@@ -40,6 +43,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
playlists: playlistsSvc,
|
playlists: playlistsSvc,
|
||||||
coverart: coverEnricher,
|
coverart: coverEnricher,
|
||||||
coverSettings: coverSettings,
|
coverSettings: coverSettings,
|
||||||
|
tagSettings: tagSettings,
|
||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
scanCfg: scanCfg,
|
scanCfg: scanCfg,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
@@ -198,9 +202,22 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
admin.Post("/cover-sources/{provider_id}/test", h.handleTestCoverSource)
|
||||||
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
||||||
|
|
||||||
|
admin.Get("/tag-sources", h.handleListTagSources)
|
||||||
|
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
||||||
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||||
|
admin.Post("/tag-sources/research", h.handleResearchTags)
|
||||||
|
|
||||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||||
|
|
||||||
|
// Recommendation tuning lab (#1250): scoring-weight
|
||||||
|
// profiles + taste-build knobs, DB-backed, live effect.
|
||||||
|
admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning)
|
||||||
|
admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning)
|
||||||
|
admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning)
|
||||||
|
// Weekly outcome trends + knob-turn markers (#1251).
|
||||||
|
admin.Get("/recommendation-trends", h.handleGetRecommendationTrends)
|
||||||
})
|
})
|
||||||
|
|
||||||
authed.Get("/playlists", h.handleListPlaylists)
|
authed.Get("/playlists", h.handleListPlaylists)
|
||||||
@@ -223,6 +240,7 @@ type handlers struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
events *playevents.Writer
|
events *playevents.Writer
|
||||||
recCfg config.RecommendationConfig
|
recCfg config.RecommendationConfig
|
||||||
|
recSettings *recsettings.Service
|
||||||
rng func() float64
|
rng func() float64
|
||||||
lidarrCfg *lidarrconfig.Service
|
lidarrCfg *lidarrconfig.Service
|
||||||
lidarrRequests *lidarrrequests.Service
|
lidarrRequests *lidarrrequests.Service
|
||||||
@@ -231,6 +249,7 @@ type handlers struct {
|
|||||||
playlists *playlists.Service
|
playlists *playlists.Service
|
||||||
coverart *coverart.Enricher
|
coverart *coverart.Enricher
|
||||||
coverSettings *coverart.SettingsService
|
coverSettings *coverart.SettingsService
|
||||||
|
tagSettings *tags.SettingsService
|
||||||
scanner *library.Scanner
|
scanner *library.Scanner
|
||||||
scanCfg library.RunScanConfig
|
scanCfg library.RunScanConfig
|
||||||
dataDir string
|
dataDir string
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -56,11 +57,12 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dbtest.ResetDB(t, pool)
|
dbtest.ResetDB(t, pool)
|
||||||
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||||
recCfg := config.RecommendationConfig{
|
recCfg := config.RecommendationConfig{
|
||||||
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
|
||||||
SkipPenalty: 1.0, JitterMagnitude: 0.1,
|
|
||||||
ContextWeight: 2.0, SimilarityWeight: 2.0,
|
|
||||||
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||||
}
|
}
|
||||||
|
recSettings, err := recsettings.New(context.Background(), pool, logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recsettings: %v", err)
|
||||||
|
}
|
||||||
lidarrCfg := lidarrconfig.New(pool)
|
lidarrCfg := lidarrconfig.New(pool)
|
||||||
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil)
|
||||||
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil)
|
||||||
@@ -70,7 +72,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
|
|||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
tracksSvc := tracks.NewService(pool, logger, nil, dataDir)
|
||||||
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
playlistsSvc := playlists.NewService(pool, logger, dataDir)
|
||||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}}
|
||||||
return h, pool
|
return h, pool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-1
@@ -31,6 +31,26 @@ type playStartedResponse struct {
|
|||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eventFutureSkew is the tolerated client-clock drift into the future
|
||||||
|
// before a timestamp is treated as bogus and replaced with now.
|
||||||
|
const eventFutureSkew = 5 * time.Minute
|
||||||
|
|
||||||
|
// clampEventTime bounds a client-supplied event timestamp to sanity:
|
||||||
|
// no earlier than the account's creation (offline replays can
|
||||||
|
// legitimately be days old, but no play can predate the user) and no
|
||||||
|
// later than now + a small skew allowance. Unbounded client clocks
|
||||||
|
// previously let a skewed device write arbitrarily old plays, which
|
||||||
|
// poisoned Rediscover's "not played in 6 months" ordering (#1246).
|
||||||
|
func clampEventTime(at, userCreatedAt, now time.Time) time.Time {
|
||||||
|
if at.Before(userCreatedAt) {
|
||||||
|
return userCreatedAt
|
||||||
|
}
|
||||||
|
if at.After(now.Add(eventFutureSkew)) {
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
return at
|
||||||
|
}
|
||||||
|
|
||||||
type okResponse struct {
|
type okResponse struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
}
|
}
|
||||||
@@ -52,7 +72,7 @@ func (h *handlers) handleEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
|
writeErr(w, apierror.BadRequest("bad_request", "invalid `at` timestamp"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
at = parsed
|
at = clampEventTime(parsed.UTC(), user.CreatedAt.Time, time.Now().UTC())
|
||||||
}
|
}
|
||||||
clientID := ""
|
clientID := ""
|
||||||
if req.ClientID != nil {
|
if req.ClientID != nil {
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
30*time.Minute, 0.5, 30000)
|
||||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.tagSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil)
|
||||||
|
|
||||||
paths := []string{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
@@ -11,28 +13,142 @@ import (
|
|||||||
const (
|
const (
|
||||||
recMetricsDefaultDays = 30
|
recMetricsDefaultDays = 30
|
||||||
recMetricsMaxDays = 365
|
recMetricsMaxDays = 365
|
||||||
|
|
||||||
|
// recMetricsLowVolume marks a family as low-confidence rather than
|
||||||
|
// hiding it: with fewer plays than this a skip rate is anecdote, not
|
||||||
|
// signal, but silently dropping the row would misread as "surface
|
||||||
|
// unused". The web renders low-confidence rows dimmed.
|
||||||
|
recMetricsLowVolume = 20
|
||||||
)
|
)
|
||||||
|
|
||||||
// recommendationMetric is one recommendation surface's outcomes.
|
// Surface intents (milestone #127): each family is judged against its
|
||||||
type recommendationMetric struct {
|
// job, not one global bar — discovery mixes are EXPECTED to run higher
|
||||||
Source string `json:"source"` // 'for_you' | 'discover' | mixes
|
// skip rates than the go-to surfaces.
|
||||||
Plays int64 `json:"plays"` // plays launched from this surface
|
const (
|
||||||
|
intentGoTo = "go_to"
|
||||||
|
intentDiscovery = "discovery"
|
||||||
|
intentDirect = "direct"
|
||||||
|
)
|
||||||
|
|
||||||
|
// surfaceMetric is one bucketed surface family's outcomes.
|
||||||
|
type surfaceMetric struct {
|
||||||
|
Key string `json:"key"` // stable family key ("for_you", "radio", …)
|
||||||
|
Label string `json:"label"` // display label
|
||||||
|
Plays int64 `json:"plays"` // plays launched from this family
|
||||||
Skips int64 `json:"skips"` // of those, marked skipped
|
Skips int64 `json:"skips"` // of those, marked skipped
|
||||||
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||||
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||||
|
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
||||||
|
// Breakdown splits the family into the pick-kind populations its
|
||||||
|
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
|
||||||
|
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
||||||
|
// that predate attribution. Present only when the family has at
|
||||||
|
// least one attributed play; the parent row remains the sum of its
|
||||||
|
// breakdown.
|
||||||
|
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// surfaceGroup is one intent band of surface families.
|
||||||
|
type surfaceGroup struct {
|
||||||
|
Intent string `json:"intent"` // go_to | discovery | direct
|
||||||
|
Label string `json:"label"`
|
||||||
|
Surfaces []surfaceMetric `json:"surfaces"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type recommendationMetricsResp struct {
|
type recommendationMetricsResp struct {
|
||||||
WindowDays int `json:"window_days"`
|
WindowDays int `json:"window_days"`
|
||||||
Sources []recommendationMetric `json:"sources"`
|
// Baseline is the control group: plays the user picked manually
|
||||||
|
// (source IS NULL). Surfaces are judged as deltas against it; nil
|
||||||
|
// when the window holds no manual plays.
|
||||||
|
Baseline *surfaceMetric `json:"baseline"`
|
||||||
|
Groups []surfaceGroup `json:"groups"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// recFamily is the bucketing target for a raw play_events.source value.
|
||||||
|
type recFamily struct {
|
||||||
|
key string
|
||||||
|
label string
|
||||||
|
intent string
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketRecSource maps a raw client-stamped source string to its stable
|
||||||
|
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
|
||||||
|
// their family so the table stays readable at any library size.
|
||||||
|
func bucketRecSource(src string) recFamily {
|
||||||
|
switch {
|
||||||
|
case src == "for_you":
|
||||||
|
return recFamily{"for_you", "For You", intentGoTo}
|
||||||
|
case src == "songs_like_artist":
|
||||||
|
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
|
||||||
|
case src == "radio" || strings.HasPrefix(src, "radio:"):
|
||||||
|
return recFamily{"radio", "Radio", intentGoTo}
|
||||||
|
case src == "discover":
|
||||||
|
return recFamily{"discover", "Discover", intentDiscovery}
|
||||||
|
case src == "deep_cuts":
|
||||||
|
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
|
||||||
|
case src == "rediscover":
|
||||||
|
return recFamily{"rediscover", "Rediscover", intentDiscovery}
|
||||||
|
case src == "new_for_you":
|
||||||
|
return recFamily{"new_for_you", "New for you", intentDiscovery}
|
||||||
|
case src == "on_this_day":
|
||||||
|
return recFamily{"on_this_day", "On this day", intentDiscovery}
|
||||||
|
case src == "first_listens":
|
||||||
|
return recFamily{"first_listens", "First listens", intentDiscovery}
|
||||||
|
case strings.HasPrefix(src, "album:"):
|
||||||
|
return recFamily{"direct_album", "Album plays", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "artist:"):
|
||||||
|
return recFamily{"direct_artist", "Artist plays", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "offline:"):
|
||||||
|
return recFamily{"offline", "Offline pools", intentDirect}
|
||||||
|
case strings.HasPrefix(src, "home:"):
|
||||||
|
return recFamily{"home", "Home sections", intentDirect}
|
||||||
|
case src == "history":
|
||||||
|
return recFamily{"history", "History", intentDirect}
|
||||||
|
default:
|
||||||
|
return recFamily{"other", "Other", intentDirect}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// familyAccum merges raw source rows into one family, carrying the
|
||||||
|
// completion sample count so the merged average stays play-weighted.
|
||||||
|
type familyAccum struct {
|
||||||
|
fam recFamily
|
||||||
|
plays int64
|
||||||
|
skips int64
|
||||||
|
completionN int64
|
||||||
|
// completionSum is avg*count re-expanded, so merging N raw rows
|
||||||
|
// reduces to a single weighted division at the end.
|
||||||
|
completionSum float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
||||||
|
a.plays += row.Plays
|
||||||
|
a.skips += row.Skips
|
||||||
|
a.completionN += row.CompletionN
|
||||||
|
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *familyAccum) metric() surfaceMetric {
|
||||||
|
m := surfaceMetric{
|
||||||
|
Key: a.fam.key,
|
||||||
|
Label: a.fam.label,
|
||||||
|
Plays: a.plays,
|
||||||
|
Skips: a.skips,
|
||||||
|
LowConfidence: a.plays < recMetricsLowVolume,
|
||||||
|
}
|
||||||
|
if a.plays > 0 {
|
||||||
|
m.SkipRate = float64(a.skips) / float64(a.plays)
|
||||||
|
}
|
||||||
|
if a.completionN > 0 {
|
||||||
|
m.AvgCompletion = a.completionSum / float64(a.completionN)
|
||||||
|
}
|
||||||
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||||
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
|
// Bucketed per-surface-family outcomes for the caller over the last `days`
|
||||||
// caller over the last `days` (default 30, capped at 365), so the operator can
|
// (default 30, capped at 365), grouped by surface intent and anchored by the
|
||||||
// see which recommendation surfaces are landing and tune the taste weights.
|
// manual-plays baseline so the numbers are judgeable, not just observable.
|
||||||
// Only plays tagged with a system-playlist source count; library/radio plays
|
|
||||||
// (no source) are excluded.
|
|
||||||
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
caller, ok := requireUser(w, r)
|
caller, ok := requireUser(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -51,28 +167,142 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
out := recommendationMetricsResp{
|
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
||||||
WindowDays: days,
|
}
|
||||||
Sources: make([]recommendationMetric, 0, len(rows)),
|
|
||||||
|
// pickKindLabels is the display vocabulary for play_events.pick_kind
|
||||||
|
// values (mirrors the CHECK in migration 0041). Every system mix that
|
||||||
|
// stamps provenance gets its breakdown from this one map — adding a
|
||||||
|
// stamping mix needs no metrics change.
|
||||||
|
var pickKindLabels = map[string]string{
|
||||||
|
"taste": "Taste picks",
|
||||||
|
"fresh": "Fresh picks",
|
||||||
|
"dormant": "Dormant artists",
|
||||||
|
"taste_unheard": "Taste-matched",
|
||||||
|
"cross_user": "Liked by others",
|
||||||
|
"random": "Random unheard",
|
||||||
|
"tier1": "Tier 1 (exact)",
|
||||||
|
"tier2": "Tier 2 (relaxed)",
|
||||||
|
"tier3": "Tier 3 (stretched)",
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
|
||||||
|
// pick_kind — plays recorded before the mix stamped provenance, or
|
||||||
|
// whose track had rotated out of the snapshot at ingestion) renders
|
||||||
|
// last, kept visible so the parent row's sums stay transparent instead
|
||||||
|
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
|
||||||
|
// vocabulary, so iterating the list is exhaustive.
|
||||||
|
var pickKindOrder = []string{
|
||||||
|
"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
|
||||||
|
"tier1", "tier2", "tier3", "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindFamily derives the sub-family for one (family, pick_kind)
|
||||||
|
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
|
||||||
|
func pickKindFamily(parent recFamily, kind string) recFamily {
|
||||||
|
if kind == "" {
|
||||||
|
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
|
||||||
}
|
}
|
||||||
|
label, ok := pickKindLabels[kind]
|
||||||
|
if !ok {
|
||||||
|
label = kind
|
||||||
|
}
|
||||||
|
return recFamily{parent.key + "_" + kind, label, parent.intent}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindBreakdown folds a family's per-pick-kind accums into its
|
||||||
|
// Breakdown rows. Attached only when at least one attributed play
|
||||||
|
// exists — an all-unattributed breakdown would just repeat the parent
|
||||||
|
// row, and families that never stamp (radio, direct plays) stay flat.
|
||||||
|
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
||||||
|
attributed := int64(0)
|
||||||
|
for kind, acc := range picks {
|
||||||
|
if kind != "" {
|
||||||
|
attributed += acc.plays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attributed == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]surfaceMetric, 0, len(picks))
|
||||||
|
for _, kind := range pickKindOrder {
|
||||||
|
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
||||||
|
out = append(out, acc.metric())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bucketMetricsResponse folds the raw per-source rows into the grouped,
|
||||||
|
// baseline-anchored response shape. Split from the handler for pure-unit
|
||||||
|
// testability.
|
||||||
|
func bucketMetricsResponse(
|
||||||
|
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
|
||||||
|
) recommendationMetricsResp {
|
||||||
|
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
|
||||||
|
families := map[string]*familyAccum{}
|
||||||
|
picks := map[string]map[string]*familyAccum{}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
source := ""
|
if row.Source == nil || *row.Source == "" {
|
||||||
if row.Source != nil {
|
baseline.add(row)
|
||||||
source = *row.Source
|
continue
|
||||||
}
|
}
|
||||||
var skipRate float64
|
fam := bucketRecSource(*row.Source)
|
||||||
if row.Plays > 0 {
|
acc, exists := families[fam.key]
|
||||||
skipRate = float64(row.Skips) / float64(row.Plays)
|
if !exists {
|
||||||
|
acc = &familyAccum{fam: fam}
|
||||||
|
families[fam.key] = acc
|
||||||
}
|
}
|
||||||
out.Sources = append(out.Sources, recommendationMetric{
|
acc.add(row)
|
||||||
Source: source,
|
// Accumulate the pick-kind population unconditionally; families
|
||||||
Plays: row.Plays,
|
// that never stamp end up all-unattributed and get no breakdown.
|
||||||
Skips: row.Skips,
|
kind := ""
|
||||||
SkipRate: skipRate,
|
if row.PickKind != nil {
|
||||||
AvgCompletion: row.AvgCompletion,
|
kind = *row.PickKind
|
||||||
})
|
}
|
||||||
|
byKind, ok := picks[fam.key]
|
||||||
|
if !ok {
|
||||||
|
byKind = map[string]*familyAccum{}
|
||||||
|
picks[fam.key] = byKind
|
||||||
|
}
|
||||||
|
pick, ok := byKind[kind]
|
||||||
|
if !ok {
|
||||||
|
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
|
||||||
|
byKind[kind] = pick
|
||||||
|
}
|
||||||
|
pick.add(row)
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
|
||||||
|
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
|
||||||
|
if baseline.plays > 0 {
|
||||||
|
m := baseline.metric()
|
||||||
|
resp.Baseline = &m
|
||||||
|
}
|
||||||
|
for _, g := range []struct{ intent, label string }{
|
||||||
|
{intentGoTo, "Go-to surfaces"},
|
||||||
|
{intentDiscovery, "Discovery mixes"},
|
||||||
|
{intentDirect, "Direct plays"},
|
||||||
|
} {
|
||||||
|
group := surfaceGroup{Intent: g.intent, Label: g.label}
|
||||||
|
for _, acc := range families {
|
||||||
|
if acc.fam.intent == g.intent {
|
||||||
|
m := acc.metric()
|
||||||
|
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
|
||||||
|
group.Surfaces = append(group.Surfaces, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(group.Surfaces) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sort.Slice(group.Surfaces, func(i, j int) bool {
|
||||||
|
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
|
||||||
|
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
|
||||||
|
}
|
||||||
|
return group.Surfaces[i].Key < group.Surfaces[j].Key
|
||||||
|
})
|
||||||
|
resp.Groups = append(resp.Groups, group)
|
||||||
|
}
|
||||||
|
return resp
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newMetricsRouter(h *handlers) chi.Router {
|
func newMetricsRouter(h *handlers) chi.Router {
|
||||||
@@ -20,17 +22,19 @@ func newMetricsRouter(h *handlers) chi.Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
||||||
// skip flag. A nil source inserts NULL (library/radio play).
|
// skip flag. A nil source inserts NULL (manual library play → baseline).
|
||||||
|
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
|
||||||
|
// except attributed for_you plays.
|
||||||
func seedSourcedPlay(
|
func seedSourcedPlay(
|
||||||
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
||||||
source *string, completion float64, skipped bool,
|
source, pickKind *string, completion float64, skipped bool,
|
||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, err := h.pool.Exec(context.Background(),
|
if _, err := h.pool.Exec(context.Background(),
|
||||||
`INSERT INTO play_events
|
`INSERT INTO play_events
|
||||||
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
|
(user_id, track_id, session_id, started_at, source, pick_kind, completion_ratio, was_skipped)
|
||||||
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
|
VALUES ($1, $2, $3, now(), $4, $5, $6, $7)`,
|
||||||
userID, trackID, sessionID, source, completion, skipped); err != nil {
|
userID, trackID, sessionID, source, pickKind, completion, skipped); err != nil {
|
||||||
t.Fatalf("seed sourced play: %v", err)
|
t.Fatalf("seed sourced play: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -45,7 +49,168 @@ func TestRecommendationMetrics_NoSession401(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
// findSurface returns the named family from any group, or nil.
|
||||||
|
func findSurface(resp recommendationMetricsResp, key string) *surfaceMetric {
|
||||||
|
for _, g := range resp.Groups {
|
||||||
|
for i := range g.Surfaces {
|
||||||
|
if g.Surfaces[i].Key == key {
|
||||||
|
return &g.Surfaces[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("for_you"), Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 2.0 / 3},
|
||||||
|
// Two radio sessions collapse into one "radio" family; weighted
|
||||||
|
// completion = (0.2*1 + 0.8*1) / 2 = 0.5.
|
||||||
|
{Source: src("radio:aaaa"), Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2},
|
||||||
|
{Source: src("radio:bbbb"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
|
||||||
|
{Source: src("album:cccc"), Plays: 1, Skips: 0, CompletionN: 0, AvgCompletion: 0},
|
||||||
|
{Source: src("discover"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
|
||||||
|
// NULL source = manual plays → baseline, not a group row.
|
||||||
|
{Source: nil, Plays: 25, Skips: 5, CompletionN: 20, AvgCompletion: 0.9},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
if resp.Baseline == nil {
|
||||||
|
t.Fatal("baseline missing")
|
||||||
|
}
|
||||||
|
if resp.Baseline.Plays != 25 || resp.Baseline.SkipRate != 0.2 {
|
||||||
|
t.Errorf("baseline = %+v, want plays=25 skip_rate=0.2", resp.Baseline)
|
||||||
|
}
|
||||||
|
if resp.Baseline.LowConfidence {
|
||||||
|
t.Error("baseline with 25 plays should not be low-confidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
radio := findSurface(resp, "radio")
|
||||||
|
if radio == nil {
|
||||||
|
t.Fatal("radio family missing")
|
||||||
|
}
|
||||||
|
if radio.Plays != 2 || radio.Skips != 1 {
|
||||||
|
t.Errorf("radio plays/skips = %d/%d, want 2/1", radio.Plays, radio.Skips)
|
||||||
|
}
|
||||||
|
if radio.AvgCompletion < 0.49 || radio.AvgCompletion > 0.51 {
|
||||||
|
t.Errorf("radio avg_completion = %.3f, want 0.5 (play-weighted merge)", radio.AvgCompletion)
|
||||||
|
}
|
||||||
|
if !radio.LowConfidence {
|
||||||
|
t.Error("radio with 2 plays should be low-confidence")
|
||||||
|
}
|
||||||
|
|
||||||
|
if s := findSurface(resp, "direct_album"); s == nil || s.Plays != 1 {
|
||||||
|
t.Errorf("direct_album = %+v, want plays=1", s)
|
||||||
|
}
|
||||||
|
if s := findSurface(resp, ""); s != nil {
|
||||||
|
t.Error("NULL source must not appear as a surface family")
|
||||||
|
}
|
||||||
|
|
||||||
|
// No attributed (taste/fresh) plays in this fixture → no breakdown;
|
||||||
|
// an all-unattributed breakdown would just repeat the parent row.
|
||||||
|
if fy := findSurface(resp, "for_you"); fy == nil || fy.Breakdown != nil {
|
||||||
|
t.Errorf("for_you breakdown = %+v, want nil without attributed plays", fy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group ordering is intent-banded: go_to before discovery before direct.
|
||||||
|
wantOrder := []string{intentGoTo, intentDiscovery, intentDirect}
|
||||||
|
if len(resp.Groups) != len(wantOrder) {
|
||||||
|
t.Fatalf("groups = %d, want %d", len(resp.Groups), len(wantOrder))
|
||||||
|
}
|
||||||
|
for i, g := range resp.Groups {
|
||||||
|
if g.Intent != wantOrder[i] {
|
||||||
|
t.Errorf("group[%d].intent = %s, want %s", i, g.Intent, wantOrder[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
kind := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("for_you"), PickKind: kind("taste"), Plays: 30, Skips: 3,
|
||||||
|
CompletionN: 30, AvgCompletion: 0.9},
|
||||||
|
{Source: src("for_you"), PickKind: kind("fresh"), Plays: 10, Skips: 4,
|
||||||
|
CompletionN: 10, AvgCompletion: 0.5},
|
||||||
|
// NULL pick_kind = plays that predate attribution.
|
||||||
|
{Source: src("for_you"), Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.7},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
fy := findSurface(resp, "for_you")
|
||||||
|
if fy == nil {
|
||||||
|
t.Fatal("for_you family missing")
|
||||||
|
}
|
||||||
|
// The parent row stays the sum of its breakdown.
|
||||||
|
if fy.Plays != 45 || fy.Skips != 8 {
|
||||||
|
t.Errorf("for_you plays/skips = %d/%d, want 45/8", fy.Plays, fy.Skips)
|
||||||
|
}
|
||||||
|
if len(fy.Breakdown) != 3 {
|
||||||
|
t.Fatalf("breakdown rows = %d, want 3 (taste, fresh, earlier)", len(fy.Breakdown))
|
||||||
|
}
|
||||||
|
wantKeys := []string{"for_you_taste", "for_you_fresh", "for_you_unattributed"}
|
||||||
|
for i, k := range wantKeys {
|
||||||
|
if fy.Breakdown[i].Key != k {
|
||||||
|
t.Errorf("breakdown[%d].key = %s, want %s", i, fy.Breakdown[i].Key, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
taste, fresh := fy.Breakdown[0], fy.Breakdown[1]
|
||||||
|
if taste.Plays != 30 || taste.SkipRate != 0.1 || taste.LowConfidence {
|
||||||
|
t.Errorf("taste = %+v, want plays=30 skip_rate=0.1 confident", taste)
|
||||||
|
}
|
||||||
|
if fresh.Plays != 10 || fresh.SkipRate != 0.4 || !fresh.LowConfidence {
|
||||||
|
t.Errorf("fresh = %+v, want plays=10 skip_rate=0.4 low-confidence", fresh)
|
||||||
|
}
|
||||||
|
// Breakdown rows never appear as their own surface families.
|
||||||
|
if s := findSurface(resp, "for_you_taste"); s != nil {
|
||||||
|
t.Error("for_you_taste must not be a top-level surface")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
|
||||||
|
// Provenance is standard (#1270): Discover's bucket stamps surface as
|
||||||
|
// a breakdown exactly like For You's taste/fresh — this is what makes
|
||||||
|
// the bucket allocation judgeable instead of a guess.
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
kind := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("discover"), PickKind: kind("dormant"), Plays: 8, Skips: 2,
|
||||||
|
CompletionN: 8, AvgCompletion: 0.8},
|
||||||
|
{Source: src("discover"), PickKind: kind("cross_user"), Plays: 6, Skips: 3,
|
||||||
|
CompletionN: 6, AvgCompletion: 0.6},
|
||||||
|
{Source: src("discover"), PickKind: kind("random"), Plays: 4, Skips: 3,
|
||||||
|
CompletionN: 4, AvgCompletion: 0.4},
|
||||||
|
// NULL pick_kind = plays that predate bucket stamping.
|
||||||
|
{Source: src("discover"), Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
d := findSurface(resp, "discover")
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("discover family missing")
|
||||||
|
}
|
||||||
|
if d.Plays != 20 || d.Skips != 8 {
|
||||||
|
t.Errorf("discover plays/skips = %d/%d, want 20/8", d.Plays, d.Skips)
|
||||||
|
}
|
||||||
|
wantKeys := []string{
|
||||||
|
"discover_dormant", "discover_cross_user", "discover_random",
|
||||||
|
"discover_unattributed",
|
||||||
|
}
|
||||||
|
if len(d.Breakdown) != len(wantKeys) {
|
||||||
|
t.Fatalf("breakdown rows = %d, want %d", len(d.Breakdown), len(wantKeys))
|
||||||
|
}
|
||||||
|
for i, k := range wantKeys {
|
||||||
|
if d.Breakdown[i].Key != k {
|
||||||
|
t.Errorf("breakdown[%d].key = %s, want %s", i, d.Breakdown[i].Key, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b := d.Breakdown[0]; b.Label != "Dormant artists" || b.Plays != 8 {
|
||||||
|
t.Errorf("dormant row = %+v, want label=Dormant artists plays=8", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
}
|
}
|
||||||
@@ -57,15 +222,17 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
|||||||
session := seedPlaySession(t, pool, user.ID, time.Now())
|
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||||
|
|
||||||
forYou := "for_you"
|
forYou := "for_you"
|
||||||
discover := "discover"
|
radioA := "radio:11111111-1111-1111-1111-111111111111"
|
||||||
|
taste, fresh := "taste", "fresh"
|
||||||
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
|
// Two attributed as taste picks, the skipped one as a fresh pick (#1249).
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false)
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false)
|
||||||
// discover: 1 play.
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true)
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
|
// A radio session play collapses into the "radio" family.
|
||||||
// library play (NULL source) — must be excluded.
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, nil, 0.8, false)
|
||||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
|
// Manual play (NULL source) — the baseline row.
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, nil, 1.0, false)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||||
req = withUser(req, user)
|
req = withUser(req, user)
|
||||||
@@ -82,15 +249,11 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
|||||||
if resp.WindowDays != recMetricsDefaultDays {
|
if resp.WindowDays != recMetricsDefaultDays {
|
||||||
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
||||||
}
|
}
|
||||||
bySource := map[string]recommendationMetric{}
|
if resp.Baseline == nil || resp.Baseline.Plays != 1 {
|
||||||
for _, m := range resp.Sources {
|
t.Fatalf("baseline = %+v, want plays=1", resp.Baseline)
|
||||||
bySource[m.Source] = m
|
|
||||||
}
|
}
|
||||||
if _, present := bySource[""]; present {
|
fy := findSurface(resp, "for_you")
|
||||||
t.Error("NULL-source (library) plays should be excluded")
|
if fy == nil {
|
||||||
}
|
|
||||||
fy, ok := bySource["for_you"]
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("for_you metrics missing")
|
t.Fatal("for_you metrics missing")
|
||||||
}
|
}
|
||||||
if fy.Plays != 3 || fy.Skips != 1 {
|
if fy.Plays != 3 || fy.Skips != 1 {
|
||||||
@@ -102,7 +265,17 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
|||||||
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
||||||
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
||||||
}
|
}
|
||||||
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
|
// Pick-kind attribution surfaces as the For You breakdown (#1249).
|
||||||
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
|
if len(fy.Breakdown) != 2 {
|
||||||
|
t.Fatalf("for_you breakdown rows = %d, want 2 (taste, fresh)", len(fy.Breakdown))
|
||||||
|
}
|
||||||
|
if b := fy.Breakdown[0]; b.Key != "for_you_taste" || b.Plays != 2 || b.Skips != 0 {
|
||||||
|
t.Errorf("breakdown[0] = %+v, want for_you_taste plays=2 skips=0", b)
|
||||||
|
}
|
||||||
|
if b := fy.Breakdown[1]; b.Key != "for_you_fresh" || b.Plays != 1 || b.Skips != 1 {
|
||||||
|
t.Errorf("breakdown[1] = %+v, want for_you_fresh plays=1 skips=1", b)
|
||||||
|
}
|
||||||
|
if radio := findSurface(resp, "radio"); radio == nil || radio.Plays != 1 {
|
||||||
|
t.Errorf("radio family = %+v, want plays=1 (collapsed from radio:<uuid>)", radio)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-10
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RadioResponse is the body of GET /api/radio.
|
// RadioResponse is the body of GET /api/radio.
|
||||||
@@ -100,16 +101,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
weights := recommendation.ScoringWeights{
|
// Scoring weights come from the DB-backed tuning lab (#1250) —
|
||||||
BaseWeight: h.recCfg.BaseWeight,
|
// read per request so an admin change takes effect live.
|
||||||
LikeBoost: h.recCfg.LikeBoost,
|
weights := h.recSettings.Weights(recsettings.ScopeRadio)
|
||||||
RecencyWeight: h.recCfg.RecencyWeight,
|
|
||||||
SkipPenalty: h.recCfg.SkipPenalty,
|
|
||||||
JitterMagnitude: h.recCfg.JitterMagnitude,
|
|
||||||
ContextWeight: h.recCfg.ContextWeight,
|
|
||||||
SimilarityWeight: h.recCfg.SimilarityWeight,
|
|
||||||
TasteWeight: h.recCfg.TasteWeight,
|
|
||||||
}
|
|
||||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
||||||
|
|
||||||
out := make([]TrackRef, 0, len(picks)+1)
|
out := make([]TrackRef, 0, len(picks)+1)
|
||||||
|
|||||||
@@ -88,20 +88,14 @@ type EventsConfig struct {
|
|||||||
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
|
SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6).
|
// RecommendationConfig holds the radio path's operational knobs. The
|
||||||
// All weights are operator-tunable; defaults match the spec recommendations.
|
// scoring WEIGHTS moved to DB-backed admin settings (#1250,
|
||||||
|
// internal/recsettings) — YAML is bootstrap-only; anything an operator
|
||||||
|
// tunes lives in the UI with live effect.
|
||||||
type RecommendationConfig struct {
|
type RecommendationConfig struct {
|
||||||
BaseWeight float64 `yaml:"base_weight"`
|
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||||
LikeBoost float64 `yaml:"like_boost"`
|
RadioSize int `yaml:"radio_size"`
|
||||||
RecencyWeight float64 `yaml:"recency_weight"`
|
RadioSizeMax int `yaml:"radio_size_max"`
|
||||||
SkipPenalty float64 `yaml:"skip_penalty"`
|
|
||||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
|
||||||
ContextWeight float64 `yaml:"context_weight"`
|
|
||||||
SimilarityWeight float64 `yaml:"similarity_weight"`
|
|
||||||
TasteWeight float64 `yaml:"taste_weight"`
|
|
||||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
|
||||||
RadioSize int `yaml:"radio_size"`
|
|
||||||
RadioSizeMax int `yaml:"radio_size_max"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
@@ -120,16 +114,6 @@ func Default() Config {
|
|||||||
SkipMaxDurationPlayedMs: 30000,
|
SkipMaxDurationPlayedMs: 30000,
|
||||||
},
|
},
|
||||||
Recommendation: RecommendationConfig{
|
Recommendation: RecommendationConfig{
|
||||||
BaseWeight: 1.0,
|
|
||||||
LikeBoost: 2.0,
|
|
||||||
RecencyWeight: 1.0,
|
|
||||||
SkipPenalty: 1.0,
|
|
||||||
JitterMagnitude: 0.1,
|
|
||||||
ContextWeight: 2.0,
|
|
||||||
SimilarityWeight: 2.0,
|
|
||||||
// Radio is seed-directed (the user picked a direction), so taste
|
|
||||||
// is a lighter nudge here than in the daily mixes (1.5).
|
|
||||||
TasteWeight: 1.0,
|
|
||||||
RecentlyPlayedHours: 1,
|
RecentlyPlayedHours: 1,
|
||||||
RadioSize: 50,
|
RadioSize: 50,
|
||||||
RadioSizeMax: 200,
|
RadioSizeMax: 200,
|
||||||
|
|||||||
@@ -211,3 +211,69 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
|
||||||
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
|
FROM tracks t
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
|
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||||
|
WHERE nt.weight > 0
|
||||||
|
AND trim(g_split.g) <> ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.track_id = t.id
|
||||||
|
AND pe.was_skipped = false
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM general_likes gl
|
||||||
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
GROUP BY t.id, t.album_id, t.artist_id
|
||||||
|
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
||||||
|
LIMIT 120
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListTasteUnheardTracksForDiscoverParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Column2 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListTasteUnheardTracksForDiscoverRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
AlbumID pgtype.UUID
|
||||||
|
ArtistID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taste-targeted novelty: unheard tracks whose genres overlap the user's
|
||||||
|
// taste-profile tags (taste_profile_tags, #796), ranked by summed tag
|
||||||
|
// weight — "new to you, but your vibe" rather than the crude random arm.
|
||||||
|
// Genres live inline on tracks.genre as a delimited string, split the
|
||||||
|
// same way the radio tag_overlap arm does (regexp_split_to_table on
|
||||||
|
// [;,]). Same exclusion filters as the other buckets. Returns nothing
|
||||||
|
// when the user has no taste tags yet (cold start), so the caller
|
||||||
|
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
||||||
|
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
|
||||||
|
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTasteUnheardTracksForDiscoverRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTasteUnheardTracksForDiscoverRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
||||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events
|
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events
|
||||||
WHERE user_id = $1 AND ended_at IS NULL
|
WHERE user_id = $1 AND ended_at IS NULL
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -79,12 +79,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
||||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events WHERE id = $1
|
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
||||||
@@ -104,15 +105,48 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSystemPickKindForTrack = `-- name: GetSystemPickKindForTrack :one
|
||||||
|
SELECT pt.pick_kind
|
||||||
|
FROM playlist_tracks pt
|
||||||
|
JOIN playlists p ON p.id = pt.playlist_id
|
||||||
|
WHERE p.user_id = $1
|
||||||
|
AND p.system_variant = $2
|
||||||
|
AND pt.track_id = $3
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSystemPickKindForTrackParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
SystemVariant *string
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks a track up in the user's CURRENT snapshot of the given system
|
||||||
|
// variant and returns its pick_kind. Used at play-ingestion time to
|
||||||
|
// freeze provenance onto the play_event — snapshots rebuild daily, so
|
||||||
|
// attribution can't be reconstructed at read time (#1249/#1270). No
|
||||||
|
// row = track not in today's snapshot (caller stores NULL); a row with
|
||||||
|
// NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
|
||||||
|
// is non-singleton (up to 3 mixes/user); a track in two of them takes
|
||||||
|
// whichever LIMIT 1 hits — acceptable, tier stamps there describe the
|
||||||
|
// same eligibility ladder.
|
||||||
|
func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPickKindForTrackParams) (*string, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSystemPickKindForTrack, arg.UserID, arg.SystemVariant, arg.TrackID)
|
||||||
|
var pick_kind *string
|
||||||
|
err := row.Scan(&pick_kind)
|
||||||
|
return pick_kind, err
|
||||||
|
}
|
||||||
|
|
||||||
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
||||||
INSERT INTO play_events (
|
INSERT INTO play_events (
|
||||||
user_id, track_id, session_id, started_at, client_id, source
|
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
|
||||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
|
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertPlayEventParams struct {
|
type InsertPlayEventParams struct {
|
||||||
@@ -122,8 +156,12 @@ type InsertPlayEventParams struct {
|
|||||||
StartedAt pgtype.Timestamptz
|
StartedAt pgtype.Timestamptz
|
||||||
ClientID *string
|
ClientID *string
|
||||||
Source *string
|
Source *string
|
||||||
|
PickKind *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pick_kind is non-NULL only for system-playlist plays whose track was
|
||||||
|
// found (with a stamped kind) in the user's live snapshot for that
|
||||||
|
// variant at ingestion time (#1249, generalized in #1270).
|
||||||
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
|
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
|
||||||
row := q.db.QueryRow(ctx, insertPlayEvent,
|
row := q.db.QueryRow(ctx, insertPlayEvent,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
@@ -132,6 +170,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
|||||||
arg.StartedAt,
|
arg.StartedAt,
|
||||||
arg.ClientID,
|
arg.ClientID,
|
||||||
arg.Source,
|
arg.Source,
|
||||||
|
arg.PickKind,
|
||||||
)
|
)
|
||||||
var i PlayEvent
|
var i PlayEvent
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -148,6 +187,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -214,7 +254,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
|
||||||
JOIN play_events pe ON pe.track_id = t.id
|
JOIN play_events pe ON pe.track_id = t.id
|
||||||
WHERE pe.session_id = $1
|
WHERE pe.session_id = $1
|
||||||
AND pe.started_at < $2
|
AND pe.started_at < $2
|
||||||
@@ -256,6 +296,8 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -291,7 +333,7 @@ SET ended_at = $2,
|
|||||||
completion_ratio = $4,
|
completion_ratio = $4,
|
||||||
was_skipped = $5
|
was_skipped = $5
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
|
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdatePlayEventEndedParams struct {
|
type UpdatePlayEventEndedParams struct {
|
||||||
@@ -328,6 +370,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
|
|||||||
&i.SessionVectorAtPlay,
|
&i.SessionVectorAtPlay,
|
||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
const listUserHistory = `-- name: ListUserHistory :many
|
const listUserHistory = `-- name: ListUserHistory :many
|
||||||
SELECT pe.id AS event_id,
|
SELECT pe.id AS event_id,
|
||||||
pe.started_at,
|
pe.started_at,
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
@@ -77,6 +77,8 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
|
||||||
JOIN general_likes l ON l.track_id = t.id
|
JOIN general_likes l ON l.track_id = t.id
|
||||||
WHERE l.user_id = $1
|
WHERE l.user_id = $1
|
||||||
ORDER BY l.liked_at DESC
|
ORDER BY l.liked_at DESC
|
||||||
@@ -297,6 +297,8 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+70
-15
@@ -383,6 +383,7 @@ type PlayEvent struct {
|
|||||||
SessionVectorAtPlay []byte
|
SessionVectorAtPlay []byte
|
||||||
ScrobbledAt pgtype.Timestamptz
|
ScrobbledAt pgtype.Timestamptz
|
||||||
Source *string
|
Source *string
|
||||||
|
PickKind *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaySession struct {
|
type PlaySession struct {
|
||||||
@@ -433,6 +434,28 @@ type PlaylistTrack struct {
|
|||||||
AlbumTitle string
|
AlbumTitle string
|
||||||
DurationSec int32
|
DurationSec int32
|
||||||
AddedAt pgtype.Timestamptz
|
AddedAt pgtype.Timestamptz
|
||||||
|
PickKind *string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationTuningAudit struct {
|
||||||
|
ID int64
|
||||||
|
ChangedAt pgtype.Timestamptz
|
||||||
|
Scope string
|
||||||
|
Action string
|
||||||
|
Changes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationWeightProfile struct {
|
||||||
|
Profile string
|
||||||
|
BaseWeight float64
|
||||||
|
LikeBoost float64
|
||||||
|
RecencyWeight float64
|
||||||
|
SkipPenalty float64
|
||||||
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
|
TasteWeight float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegistrationSetting struct {
|
type RegistrationSetting struct {
|
||||||
@@ -509,6 +532,21 @@ type SystemPlaylistRun struct {
|
|||||||
LastError *string
|
LastError *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TagProviderSetting struct {
|
||||||
|
ProviderID string
|
||||||
|
Enabled bool
|
||||||
|
ApiKey *string
|
||||||
|
DisplayOrder int32
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagSourcesMetum struct {
|
||||||
|
ID bool
|
||||||
|
CurrentVersion int32
|
||||||
|
LastRegisteredProvidersHash string
|
||||||
|
}
|
||||||
|
|
||||||
type TasteProfileArtist struct {
|
type TasteProfileArtist struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
@@ -523,22 +561,33 @@ type TasteProfileTag struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TasteTuning struct {
|
||||||
|
Singleton bool
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Title string
|
Title string
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
TrackNumber *int32
|
TrackNumber *int32
|
||||||
DiscNumber *int32
|
DiscNumber *int32
|
||||||
DurationMs int32
|
DurationMs int32
|
||||||
FilePath string
|
FilePath string
|
||||||
FileSize int64
|
FileSize int64
|
||||||
FileFormat string
|
FileFormat string
|
||||||
Bitrate *int32
|
Bitrate *int32
|
||||||
Mbid *string
|
Mbid *string
|
||||||
Genre *string
|
Genre *string
|
||||||
AddedAt pgtype.Timestamptz
|
AddedAt pgtype.Timestamptz
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
TagSource *string
|
||||||
|
TagSourcesVersion int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackSimilarity struct {
|
type TrackSimilarity struct {
|
||||||
@@ -549,6 +598,12 @@ type TrackSimilarity struct {
|
|||||||
FetchedAt pgtype.Timestamptz
|
FetchedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrackTag struct {
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
Tag string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Username string
|
Username string
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
|
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
|
||||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
|
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
|
||||||
SELECT
|
SELECT
|
||||||
$1::uuid,
|
$1::uuid,
|
||||||
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
|
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
|
||||||
@@ -20,24 +20,27 @@ SELECT
|
|||||||
t.title,
|
t.title,
|
||||||
artists.name,
|
artists.name,
|
||||||
albums.title,
|
albums.title,
|
||||||
(t.duration_ms / 1000)::integer
|
(t.duration_ms / 1000)::integer,
|
||||||
|
$2::text
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
WHERE t.id = $2::uuid
|
WHERE t.id = $3::uuid
|
||||||
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at
|
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind
|
||||||
`
|
`
|
||||||
|
|
||||||
type AppendPlaylistTrackParams struct {
|
type AppendPlaylistTrackParams struct {
|
||||||
PlaylistID pgtype.UUID
|
PlaylistID pgtype.UUID
|
||||||
|
PickKind *string
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inserts at the next available position. Snapshot fields are copied
|
// Inserts at the next available position. Snapshot fields are copied
|
||||||
// from the tracks/albums/artists join at insert time. tracks.duration_ms
|
// from the tracks/albums/artists join at insert time. tracks.duration_ms
|
||||||
// is converted to seconds for the snapshot.
|
// is converted to seconds for the snapshot. pick_kind is NULL for manual
|
||||||
|
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
|
||||||
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
|
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
|
||||||
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID)
|
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
|
||||||
var i PlaylistTrack
|
var i PlaylistTrack
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&i.PlaylistID,
|
&i.PlaylistID,
|
||||||
@@ -48,6 +51,7 @@ func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTra
|
|||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.DurationSec,
|
&i.DurationSec,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
|
&i.PickKind,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -257,7 +261,7 @@ func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListA
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
|
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
|
||||||
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at,
|
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind,
|
||||||
t.id AS live_track_id,
|
t.id AS live_track_id,
|
||||||
albums.id AS album_id,
|
albums.id AS album_id,
|
||||||
artists.id AS artist_id
|
artists.id AS artist_id
|
||||||
@@ -278,6 +282,7 @@ type ListPlaylistTracksRow struct {
|
|||||||
AlbumTitle string
|
AlbumTitle string
|
||||||
DurationSec int32
|
DurationSec int32
|
||||||
AddedAt pgtype.Timestamptz
|
AddedAt pgtype.Timestamptz
|
||||||
|
PickKind *string
|
||||||
LiveTrackID pgtype.UUID
|
LiveTrackID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
@@ -305,6 +310,7 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID
|
|||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.DurationSec,
|
&i.DurationSec,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
|
&i.PickKind,
|
||||||
&i.LiveTrackID,
|
&i.LiveTrackID,
|
||||||
&i.AlbumID,
|
&i.AlbumID,
|
||||||
&i.ArtistID,
|
&i.ArtistID,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ WITH plays AS (
|
|||||||
WHERE user_id = $2 AND was_skipped = false
|
WHERE user_id = $2 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -161,6 +161,8 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -181,7 +183,7 @@ WITH plays AS (
|
|||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -240,6 +242,8 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -577,7 +581,7 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
|
|||||||
|
|
||||||
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
@@ -650,6 +654,8 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
@@ -764,7 +770,7 @@ random_fill AS (
|
|||||||
LIMIT $9
|
LIMIT $9
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
@@ -862,6 +868,8 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
|
|||||||
@@ -12,19 +12,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
|
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
pe.source,
|
pe.source,
|
||||||
|
pe.pick_kind,
|
||||||
count(*)::bigint AS plays,
|
count(*)::bigint AS plays,
|
||||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
COALESCE(
|
count(pe.completion_ratio)::bigint AS completion_n,
|
||||||
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
|
||||||
0)::float8 AS avg_completion
|
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.source IS NOT NULL
|
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
GROUP BY pe.source
|
GROUP BY pe.source, pe.pick_kind
|
||||||
ORDER BY plays DESC
|
ORDER BY plays DESC
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -35,18 +33,17 @@ type RecommendationSourceMetricsForUserParams struct {
|
|||||||
|
|
||||||
type RecommendationSourceMetricsForUserRow struct {
|
type RecommendationSourceMetricsForUserRow struct {
|
||||||
Source *string
|
Source *string
|
||||||
|
PickKind *string
|
||||||
Plays int64
|
Plays int64
|
||||||
Skips int64
|
Skips int64
|
||||||
|
CompletionN int64
|
||||||
AvgCompletion float64
|
AvgCompletion float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
|
||||||
// operator can see whether each recommendation surface is landing and tune the
|
|
||||||
// taste weights. Source is stamped on play_events when a play is launched from
|
|
||||||
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
|
||||||
// NULL for library / radio / user-playlist plays, which are excluded here.
|
|
||||||
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||||
// mean completion ratio over plays that recorded one (0 when none did).
|
// mean completion ratio over the completion_n plays that recorded one.
|
||||||
|
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
||||||
|
// it is NULL for every other source, so those still group to one row.
|
||||||
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -58,8 +55,10 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
|
|||||||
var i RecommendationSourceMetricsForUserRow
|
var i RecommendationSourceMetricsForUserRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&i.Source,
|
&i.Source,
|
||||||
|
&i.PickKind,
|
||||||
&i.Plays,
|
&i.Plays,
|
||||||
&i.Skips,
|
&i.Skips,
|
||||||
|
&i.CompletionN,
|
||||||
&i.AvgCompletion,
|
&i.AvgCompletion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -71,3 +70,83 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recommendationWeeklyTrends = `-- name: RecommendationWeeklyTrends :many
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
date_trunc('week', pe.started_at)::date AS week_start,
|
||||||
|
pe.source,
|
||||||
|
count(*)::bigint AS plays,
|
||||||
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
|
count(pe.completion_ratio)::bigint AS completion_n,
|
||||||
|
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
|
||||||
|
count(*) FILTER (WHERE tpa.artist_id IS NOT NULL)::bigint AS taste_hits
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
LEFT JOIN taste_profile_artists tpa
|
||||||
|
ON tpa.user_id = pe.user_id
|
||||||
|
AND tpa.artist_id = t.artist_id
|
||||||
|
AND tpa.weight > 0
|
||||||
|
WHERE pe.started_at > now() - ($1::int * INTERVAL '1 week')
|
||||||
|
GROUP BY 1, 2
|
||||||
|
ORDER BY 1, 2
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecommendationWeeklyTrendsRow struct {
|
||||||
|
WeekStart pgtype.Date
|
||||||
|
Source *string
|
||||||
|
Plays int64
|
||||||
|
Skips int64
|
||||||
|
CompletionN int64
|
||||||
|
AvgCompletion float64
|
||||||
|
TasteHits int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||||
|
// operator can see whether each recommendation surface is landing and tune the
|
||||||
|
// taste weights. Source is stamped on play_events when a play is launched from
|
||||||
|
// a recommendation surface; NULL means the user picked the track manually —
|
||||||
|
// those rows are INCLUDED here as the baseline control group the surfaces are
|
||||||
|
// judged against (milestone #127: delta-vs-baseline is what makes the numbers
|
||||||
|
// actionable). Raw source strings are bucketed into stable surface families in
|
||||||
|
// the Go handler; completion_n is carried so family merges can weight
|
||||||
|
// avg_completion correctly.
|
||||||
|
// Weekly per-source outcome series for the tuning lab's trend view
|
||||||
|
// (#1251). Aggregated across ALL users: the tuning knobs are global,
|
||||||
|
// so judging a knob turn needs global outcomes — rows carry rates
|
||||||
|
// only, no track or user identity. NULL-source (manual) rows are
|
||||||
|
// included as the baseline family.
|
||||||
|
//
|
||||||
|
// taste_hits counts plays whose track's artist has a positive weight
|
||||||
|
// in that user's CURRENT taste profile — the "cheap recompute" option:
|
||||||
|
// retroactive over the whole window, at the cost of drift (the profile
|
||||||
|
// is today's, the play may be weeks old). Good enough to read whether
|
||||||
|
// a surface is feeding taste-fitting tracks.
|
||||||
|
// $1 window in weeks.
|
||||||
|
func (q *Queries) RecommendationWeeklyTrends(ctx context.Context, weeks int32) ([]RecommendationWeeklyTrendsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, recommendationWeeklyTrends, weeks)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationWeeklyTrendsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationWeeklyTrendsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.WeekStart,
|
||||||
|
&i.Source,
|
||||||
|
&i.Plays,
|
||||||
|
&i.Skips,
|
||||||
|
&i.CompletionN,
|
||||||
|
&i.AvgCompletion,
|
||||||
|
&i.TasteHits,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: recommendation_tuning.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const getTasteTuning = `-- name: GetTasteTuning :one
|
||||||
|
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at FROM taste_tuning WHERE singleton = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getTasteTuning)
|
||||||
|
var i TasteTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.HalfLifeDays,
|
||||||
|
&i.EngagementHardSkip,
|
||||||
|
&i.EngagementNeutral,
|
||||||
|
&i.EngagementFull,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertTuningAudit = `-- name: InsertTuningAudit :exec
|
||||||
|
INSERT INTO recommendation_tuning_audit (scope, action, changes)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertTuningAuditParams struct {
|
||||||
|
Scope string
|
||||||
|
Action string
|
||||||
|
Changes []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// changes is a jsonb array of {field, old, new} objects.
|
||||||
|
func (q *Queries) InsertTuningAudit(ctx context.Context, arg InsertTuningAuditParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertTuningAudit, arg.Scope, arg.Action, arg.Changes)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTuningAudit = `-- name: ListTuningAudit :many
|
||||||
|
SELECT id, changed_at, scope, action, changes
|
||||||
|
FROM recommendation_tuning_audit
|
||||||
|
ORDER BY changed_at DESC, id DESC
|
||||||
|
LIMIT $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Newest first; consumed by the metrics trend view (#1251) to annotate
|
||||||
|
// knob turns on the timeline.
|
||||||
|
func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTuningAudit, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationTuningAudit
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationTuningAudit
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.ChangedAt,
|
||||||
|
&i.Scope,
|
||||||
|
&i.Action,
|
||||||
|
&i.Changes,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listWeightProfiles = `-- name: ListWeightProfiles :many
|
||||||
|
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listWeightProfiles)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationWeightProfile
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationWeightProfile
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Profile,
|
||||||
|
&i.BaseWeight,
|
||||||
|
&i.LikeBoost,
|
||||||
|
&i.RecencyWeight,
|
||||||
|
&i.SkipPenalty,
|
||||||
|
&i.JitterMagnitude,
|
||||||
|
&i.ContextWeight,
|
||||||
|
&i.SimilarityWeight,
|
||||||
|
&i.TasteWeight,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTasteTuning = `-- name: UpdateTasteTuning :one
|
||||||
|
UPDATE taste_tuning
|
||||||
|
SET half_life_days = $1,
|
||||||
|
engagement_hard_skip = $2,
|
||||||
|
engagement_neutral = $3,
|
||||||
|
engagement_full = $4,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE singleton = true
|
||||||
|
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTasteTuningParams struct {
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateTasteTuning,
|
||||||
|
arg.HalfLifeDays,
|
||||||
|
arg.EngagementHardSkip,
|
||||||
|
arg.EngagementNeutral,
|
||||||
|
arg.EngagementFull,
|
||||||
|
)
|
||||||
|
var i TasteTuning
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Singleton,
|
||||||
|
&i.HalfLifeDays,
|
||||||
|
&i.EngagementHardSkip,
|
||||||
|
&i.EngagementNeutral,
|
||||||
|
&i.EngagementFull,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWeightProfile = `-- name: UpdateWeightProfile :one
|
||||||
|
UPDATE recommendation_weight_profiles
|
||||||
|
SET base_weight = $2,
|
||||||
|
like_boost = $3,
|
||||||
|
recency_weight = $4,
|
||||||
|
skip_penalty = $5,
|
||||||
|
jitter_magnitude = $6,
|
||||||
|
context_weight = $7,
|
||||||
|
similarity_weight = $8,
|
||||||
|
taste_weight = $9,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE profile = $1
|
||||||
|
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateWeightProfileParams struct {
|
||||||
|
Profile string
|
||||||
|
BaseWeight float64
|
||||||
|
LikeBoost float64
|
||||||
|
RecencyWeight float64
|
||||||
|
SkipPenalty float64
|
||||||
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
|
TasteWeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
|
||||||
|
row := q.db.QueryRow(ctx, updateWeightProfile,
|
||||||
|
arg.Profile,
|
||||||
|
arg.BaseWeight,
|
||||||
|
arg.LikeBoost,
|
||||||
|
arg.RecencyWeight,
|
||||||
|
arg.SkipPenalty,
|
||||||
|
arg.JitterMagnitude,
|
||||||
|
arg.ContextWeight,
|
||||||
|
arg.SimilarityWeight,
|
||||||
|
arg.TasteWeight,
|
||||||
|
)
|
||||||
|
var i RecommendationWeightProfile
|
||||||
|
err := row.Scan(
|
||||||
|
&i.Profile,
|
||||||
|
&i.BaseWeight,
|
||||||
|
&i.LikeBoost,
|
||||||
|
&i.RecencyWeight,
|
||||||
|
&i.SkipPenalty,
|
||||||
|
&i.JitterMagnitude,
|
||||||
|
&i.ContextWeight,
|
||||||
|
&i.SimilarityWeight,
|
||||||
|
&i.TasteWeight,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
)
|
||||||
|
return i, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||||
|
INSERT INTO taste_tuning (
|
||||||
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
|
engagement_neutral, engagement_full
|
||||||
|
) VALUES (true, $1, $2, $3, $4)
|
||||||
|
ON CONFLICT (singleton) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertTasteTuningDefaultsParams struct {
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertTasteTuningDefaults,
|
||||||
|
arg.HalfLifeDays,
|
||||||
|
arg.EngagementHardSkip,
|
||||||
|
arg.EngagementNeutral,
|
||||||
|
arg.EngagementFull,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
|
||||||
|
|
||||||
|
INSERT INTO recommendation_weight_profiles (
|
||||||
|
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||||
|
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (profile) DO NOTHING
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertWeightProfileDefaultsParams struct {
|
||||||
|
Profile string
|
||||||
|
BaseWeight float64
|
||||||
|
LikeBoost float64
|
||||||
|
RecencyWeight float64
|
||||||
|
SkipPenalty float64
|
||||||
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64
|
||||||
|
SimilarityWeight float64
|
||||||
|
TasteWeight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendation tuning lab queries (#1250). Seeding happens via the
|
||||||
|
// recsettings boot reconcile; shipped defaults live in Go only.
|
||||||
|
// Boot reconcile: insert the shipped defaults for a profile if the row
|
||||||
|
// doesn't exist yet. Never overwrites operator-tuned values.
|
||||||
|
func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertWeightProfileDefaults,
|
||||||
|
arg.Profile,
|
||||||
|
arg.BaseWeight,
|
||||||
|
arg.LikeBoost,
|
||||||
|
arg.RecencyWeight,
|
||||||
|
arg.SkipPenalty,
|
||||||
|
arg.JitterMagnitude,
|
||||||
|
arg.ContextWeight,
|
||||||
|
arg.SimilarityWeight,
|
||||||
|
arg.TasteWeight,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -27,12 +27,20 @@ play_counts AS (
|
|||||||
FROM play_events
|
FROM play_events
|
||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
|
),
|
||||||
|
skip_counts AS (
|
||||||
|
SELECT track_id, COUNT(*) AS c
|
||||||
|
FROM play_events
|
||||||
|
WHERE user_id = $1 AND was_skipped = true
|
||||||
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
||||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||||
|
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||||
WHERE COALESCE(pc.c, 0) <= 2
|
WHERE COALESCE(pc.c, 0) <= 2
|
||||||
|
AND COALESCE(sc.c, 0) < 2
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
@@ -60,6 +68,11 @@ type ListDeepCutsTracksRow struct {
|
|||||||
// gives per-play variety on top.
|
// gives per-play variety on top.
|
||||||
// #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
|
// #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
|
||||||
// has liked OR played heavily (>=5 non-skip plays across the artist).
|
// has liked OR played heavily (>=5 non-skip plays across the artist).
|
||||||
|
// Tracks the user has skipped twice or more don't qualify (#1257):
|
||||||
|
// eligibility used to count only unskipped plays, so the most actively
|
||||||
|
// rejected tracks read as "barely heard" and kept being re-offered.
|
||||||
|
// Threshold 2 so a single accidental skip doesn't banish a track;
|
||||||
|
// passive-signal only (no dislike UI, rule #101).
|
||||||
// $1 user_id, $2 date string.
|
// $1 user_id, $2 date string.
|
||||||
func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) {
|
func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracksParams) ([]ListDeepCutsTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2)
|
rows, err := q.db.Query(ctx, listDeepCutsTracks, arg.UserID, arg.Column2)
|
||||||
@@ -82,31 +95,49 @@ func (q *Queries) ListDeepCutsTracks(ctx context.Context, arg ListDeepCutsTracks
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listFirstListensTracks = `-- name: ListFirstListensTracks :many
|
const listFirstListensTracks = `-- name: ListFirstListensTracks :many
|
||||||
WITH heard_albums AS (
|
WITH attempted AS (
|
||||||
SELECT DISTINCT t.album_id
|
SELECT DISTINCT pe.track_id
|
||||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
|
||||||
),
|
),
|
||||||
played_artists AS (
|
album_attempts AS (
|
||||||
|
SELECT t.album_id,
|
||||||
|
COUNT(a.track_id) AS attempted_count,
|
||||||
|
COUNT(*) AS track_count
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN attempted a ON a.track_id = t.id
|
||||||
|
GROUP BY t.album_id
|
||||||
|
),
|
||||||
|
attempted_artists AS (
|
||||||
SELECT DISTINCT t.artist_id
|
SELECT DISTINCT t.artist_id
|
||||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||||
WHERE pe.user_id = $1
|
),
|
||||||
|
albums_tiered AS (
|
||||||
|
SELECT aa.album_id,
|
||||||
|
CASE
|
||||||
|
WHEN aa.attempted_count = 0 THEN 1
|
||||||
|
WHEN aa.attempted_count::float / GREATEST(aa.track_count, 1) <= 0.25 THEN 2
|
||||||
|
END AS tier
|
||||||
|
FROM album_attempts aa
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id)
|
JOIN albums_tiered alt ON alt.album_id = al.id
|
||||||
|
WHERE alt.tier IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
|
alt.tier,
|
||||||
(CASE
|
(CASE
|
||||||
WHEN EXISTS (
|
WHEN EXISTS (
|
||||||
SELECT 1 FROM general_likes_artists gla
|
SELECT 1 FROM general_likes_artists gla
|
||||||
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
|
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
|
||||||
) THEN 0
|
) THEN 0
|
||||||
WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1
|
WHEN al.artist_id IN (SELECT artist_id FROM attempted_artists) THEN 1
|
||||||
ELSE 2
|
ELSE 2
|
||||||
END),
|
END),
|
||||||
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||||
@@ -117,12 +148,25 @@ type ListFirstListensTracksRow struct {
|
|||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
|
Tier int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// #423 First Listens: albums the user has never played any track of.
|
// #423 First Listens: songs the user has never heard or even
|
||||||
// Tiered: liked-artist albums first, then played-artist albums, then
|
// attempted. "Attempted" is track-level with a >=30s listen threshold
|
||||||
// the rest — album-coherent within each tier. Not diversity-capped
|
// (#1268, operator decision 2026-07-03) — the old version disqualified
|
||||||
// (whole-album discovery). $1 user_id.
|
// a whole album on ANY play_event, so a 2-second accidental brush
|
||||||
|
// banished it permanently.
|
||||||
|
//
|
||||||
|
// tier 1 the exact desire: albums with ZERO attempted tracks
|
||||||
|
// tier 2 step back: barely-attempted albums (<=25% of tracks
|
||||||
|
// reached 30s) — brushed, never explored. The already-
|
||||||
|
// attempted tracks themselves are excluded; they're not
|
||||||
|
// first listens.
|
||||||
|
//
|
||||||
|
// Within each tier, affinity-ordered as before: liked artist, then
|
||||||
|
// attempted-played artist (>=30s — skip-only contact isn't trust),
|
||||||
|
// then the rest; album-coherent. The tier lands on
|
||||||
|
// playlist_tracks.pick_kind via the producer. $1 user_id.
|
||||||
func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) {
|
func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID) ([]ListFirstListensTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listFirstListensTracks, userID)
|
rows, err := q.db.Query(ctx, listFirstListensTracks, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -132,7 +176,12 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
|
|||||||
var items []ListFirstListensTracksRow
|
var items []ListFirstListensTracksRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListFirstListensTracksRow
|
var i ListFirstListensTracksRow
|
||||||
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.AlbumID,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.Tier,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -144,39 +193,76 @@ func (q *Queries) ListFirstListensTracks(ctx context.Context, userID pgtype.UUID
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listNewForYouTracks = `-- name: ListNewForYouTracks :many
|
const listNewForYouTracks = `-- name: ListNewForYouTracks :many
|
||||||
WITH affinity_artists AS (
|
WITH attempted AS (
|
||||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
SELECT DISTINCT pe.track_id
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
|
||||||
|
),
|
||||||
|
consumed_albums AS (
|
||||||
|
SELECT DISTINCT t.album_id
|
||||||
|
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||||
|
),
|
||||||
|
affinity_artists AS (
|
||||||
|
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||||
UNION
|
UNION
|
||||||
SELECT t.artist_id
|
SELECT t.artist_id
|
||||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
|
||||||
GROUP BY t.artist_id
|
GROUP BY t.artist_id
|
||||||
HAVING COUNT(*) >= 3
|
HAVING COUNT(*) >= 3
|
||||||
|
),
|
||||||
|
albums_tiered AS (
|
||||||
|
SELECT al.id AS album_id, al.created_at,
|
||||||
|
CASE
|
||||||
|
WHEN al.created_at >= now() - interval '30 days'
|
||||||
|
AND al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 1
|
||||||
|
WHEN al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS tier
|
||||||
|
FROM albums al
|
||||||
|
WHERE al.created_at >= now() - interval '90 days'
|
||||||
|
AND al.id NOT IN (SELECT album_id FROM consumed_albums)
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
||||||
JOIN affinity_artists aa ON aa.artist_id = al.artist_id
|
WHERE NOT EXISTS (
|
||||||
WHERE al.created_at >= now() - interval '30 days'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
|
||||||
LIMIT 200
|
t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||||
|
LIMIT 300
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListNewForYouTracksRow struct {
|
type ListNewForYouTracksRow struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
|
Tier int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// #421 New for you: tracks from albums added in the last 30 days
|
// #421 New for you, tiered per project rule #131 (#1267). The old
|
||||||
// whose artist the user has liked OR played (>=3 non-skip). Album-
|
// single hard rule (added <30d AND direct affinity) had two failure
|
||||||
// coherent (newest album first, then disc/track) — the producer does
|
// modes with one root: no notion of consumption — the album you
|
||||||
// NOT diversity-cap these; they're meant as whole-album discovery.
|
// devoured in week one crowded the mix for three more weeks, and
|
||||||
// $1 user_id.
|
// after a quiet month nothing qualified at all.
|
||||||
|
//
|
||||||
|
// "Consumed" = any track attempted for >=30s (the First Listens
|
||||||
|
// threshold, #1268): once you've meaningfully engaged, the album is
|
||||||
|
// no longer news. Applies to every tier — played albums leave the
|
||||||
|
// mix the next build.
|
||||||
|
//
|
||||||
|
// tier 1 the exact desire: unconsumed albums added <30d by
|
||||||
|
// direct-affinity artists (liked, or >=3 attempted plays)
|
||||||
|
// tier 2 step back a little: unconsumed affinity albums from the
|
||||||
|
// wider 30-90d window — added while you weren't looking
|
||||||
|
// tier 3 step back more: any unconsumed album added <90d
|
||||||
|
// regardless of affinity, newest first
|
||||||
|
//
|
||||||
|
// Album-coherent within tiers (newest album first, then disc/track);
|
||||||
|
// the producer fills top-down and rotates within tier blocks. The
|
||||||
|
// tier lands on playlist_tracks.pick_kind so metrics can price what
|
||||||
|
// each step-back trades away. $1 user_id.
|
||||||
func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) {
|
func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) ([]ListNewForYouTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listNewForYouTracks, userID)
|
rows, err := q.db.Query(ctx, listNewForYouTracks, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -186,7 +272,12 @@ func (q *Queries) ListNewForYouTracks(ctx context.Context, userID pgtype.UUID) (
|
|||||||
var items []ListNewForYouTracksRow
|
var items []ListNewForYouTracksRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListNewForYouTracksRow
|
var i ListNewForYouTracksRow
|
||||||
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.AlbumID,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.Tier,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -203,7 +294,10 @@ WITH windowed AS (
|
|||||||
FROM play_events
|
FROM play_events
|
||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
AND started_at < now() - interval '30 days'
|
AND started_at < now() - interval '30 days'
|
||||||
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
|
AND LEAST(
|
||||||
|
ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date)),
|
||||||
|
365 - ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date))
|
||||||
|
) <= 10
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
@@ -235,6 +329,13 @@ type ListOnThisDayTracksRow struct {
|
|||||||
// and window ±7→±10 so it surfaces on a months-old library instead
|
// and window ±7→±10 so it surfaces on a months-old library instead
|
||||||
// of needing a full year of history; still skips cleanly (no rows →
|
// of needing a full year of history; still skips cleanly (no rows →
|
||||||
// no playlist) when there's no qualifying history yet.
|
// no playlist) when there's no qualifying history yet.
|
||||||
|
// Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs
|
||||||
|
// Jan 3 read as 359 days apart, silently gutting the window for ~3
|
||||||
|
// weeks around every New Year — precisely when holiday nostalgia is
|
||||||
|
// the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of
|
||||||
|
// ±1 is absorbed by the ±10 window. The build date ($2, already here
|
||||||
|
// for the md5 rotation) anchors "today" instead of now() so the
|
||||||
|
// window is testable and consistent with the mix's daily determinism.
|
||||||
// $1 user_id, $2 date string.
|
// $1 user_id, $2 date string.
|
||||||
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
|
func (q *Queries) ListOnThisDayTracks(ctx context.Context, arg ListOnThisDayTracksParams) ([]ListOnThisDayTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
|
rows, err := q.db.Query(ctx, listOnThisDayTracks, arg.UserID, arg.Column2)
|
||||||
@@ -262,37 +363,32 @@ WITH stats AS (
|
|||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||||
GROUP BY pe.track_id
|
GROUP BY pe.track_id
|
||||||
),
|
|
||||||
deep AS (
|
|
||||||
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
|
|
||||||
FROM tracks t
|
|
||||||
JOIN stats s ON s.track_id = t.id
|
|
||||||
WHERE s.c >= 5
|
|
||||||
AND s.last_at <= now() - interval '6 months'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
||||||
)
|
|
||||||
),
|
|
||||||
shallow AS (
|
|
||||||
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
|
|
||||||
FROM tracks t
|
|
||||||
JOIN stats s ON s.track_id = t.id
|
|
||||||
WHERE s.c >= 5
|
|
||||||
AND s.last_at <= now() - interval '30 days'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
SELECT id, album_id, artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM (
|
FROM tracks t
|
||||||
SELECT id, album_id, artist_id, c, tier FROM deep
|
JOIN stats s ON s.track_id = t.id
|
||||||
UNION ALL
|
WHERE s.c >= 3
|
||||||
SELECT id, album_id, artist_id, c, tier FROM shallow
|
AND s.last_at <= now() - interval '30 days'
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM deep)
|
AND NOT EXISTS (
|
||||||
) u
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
ORDER BY tier, c DESC, id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
-- Declined-resurfacing cooldown (#1258): a skip on a
|
||||||
|
-- rediscover-sourced play is the user explicitly saying "I've moved
|
||||||
|
-- on" to the exact invitation this mix extends — such tracks sit
|
||||||
|
-- out ~90 days instead of re-qualifying the next day. Passive
|
||||||
|
-- signal only (rule #101).
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM play_events rp
|
||||||
|
WHERE rp.user_id = $1
|
||||||
|
AND rp.track_id = t.id
|
||||||
|
AND rp.source = 'rediscover'
|
||||||
|
AND rp.was_skipped = true
|
||||||
|
AND rp.started_at > now() - interval '90 days'
|
||||||
|
)
|
||||||
|
ORDER BY (s.last_at <= now() - interval '6 months') DESC,
|
||||||
|
(s.c >= 5) DESC,
|
||||||
|
s.c DESC, t.id
|
||||||
LIMIT 200
|
LIMIT 200
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -302,13 +398,17 @@ type ListRediscoverTracksRow struct {
|
|||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
// #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
|
// #420 Rediscover: tracks the user played a lot but has drifted away
|
||||||
// has drifted away from. Tiered so a young library still gets a mix:
|
// from. One blended pool (issue #1246: the old two-tier UNION was
|
||||||
//
|
// all-or-nothing — a single ">=6 months cold" row suppressed the whole
|
||||||
// tier 0 not played in the last 6 months (true rediscovery)
|
// ">=30 days" tier, which is how a one-song playlist shipped; the 6mo
|
||||||
// tier 1 (only if tier 0 empty) not played in the last 30 days
|
// tier was also a strict subset of the 30d tier). Eligibility is >=3
|
||||||
//
|
// non-skip plays and >=30 days cold; the bar sits at 3 rather than 5
|
||||||
// Ordered by historical affection. $1 user_id.
|
// so a weeks-old library still fields a pool — on young histories the
|
||||||
|
// >=5-play tracks are precisely the ones still in rotation. Ordering
|
||||||
|
// prefers true rediscoveries (>=6 months cold), then strong affection
|
||||||
|
// (>=5 plays), then raw play count; the producer's minimum floor
|
||||||
|
// decides whether the pool is big enough to ship at all. $1 user_id.
|
||||||
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
|
func (q *Queries) ListRediscoverTracks(ctx context.Context, userID pgtype.UUID) ([]ListRediscoverTracksRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
|
rows, err := q.db.Query(ctx, listRediscoverTracks, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -290,9 +290,13 @@ func (q *Queries) ListPlaylistsByUserAndKind(ctx context.Context, arg ListPlayli
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pickSeedArtists = `-- name: PickSeedArtists :many
|
const pickSeedArtists = `-- name: PickSeedArtists :many
|
||||||
WITH plays AS (
|
WITH liked AS (
|
||||||
|
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||||
|
),
|
||||||
|
recent7 AS (
|
||||||
SELECT t.artist_id,
|
SELECT t.artist_id,
|
||||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
|
0 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
@@ -300,27 +304,80 @@ WITH plays AS (
|
|||||||
AND t.artist_id IS NOT NULL
|
AND t.artist_id IS NOT NULL
|
||||||
GROUP BY t.artist_id
|
GROUP BY t.artist_id
|
||||||
),
|
),
|
||||||
liked AS (
|
recent30 AS (
|
||||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
SELECT t.artist_id,
|
||||||
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
|
1 AS tier
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.started_at > now() - INTERVAL '30 days'
|
||||||
|
AND t.artist_id IS NOT NULL
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
alltime AS (
|
||||||
|
SELECT t.artist_id,
|
||||||
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
|
2 AS tier
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND t.artist_id IS NOT NULL
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
likedonly AS (
|
||||||
|
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
|
||||||
|
FROM liked l
|
||||||
|
),
|
||||||
|
chosen AS (
|
||||||
|
SELECT artist_id, play_count, tier FROM recent7
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, play_count, tier FROM recent30
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, play_count, tier FROM alltime
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM recent30)
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, play_count, tier FROM likedonly
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM recent30)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM alltime)
|
||||||
)
|
)
|
||||||
SELECT p.artist_id,
|
SELECT c.artist_id,
|
||||||
(p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score
|
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
|
||||||
FROM plays p
|
c.tier::int AS tier
|
||||||
LEFT JOIN liked l ON l.artist_id = p.artist_id
|
FROM chosen c
|
||||||
ORDER BY score DESC, p.artist_id
|
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
||||||
LIMIT 5
|
ORDER BY score DESC, c.artist_id
|
||||||
|
LIMIT 12
|
||||||
`
|
`
|
||||||
|
|
||||||
type PickSeedArtistsRow struct {
|
type PickSeedArtistsRow struct {
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
Score int64
|
Score int64
|
||||||
|
Tier int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-5 most-engaged distinct artist candidates in the user's last 7
|
// Top-12 most-engaged distinct artist candidates, tiered so the
|
||||||
// days. The Go-side picker (pickSeedArtistsForDay) shuffles these
|
// "Songs like X" mixes never silently vanish (#1255): the old hard
|
||||||
// daily-deterministically and takes the first 3 so the set of
|
// 7-day window emptied the seed pool after a quiet week, and the
|
||||||
// "Songs like X" mixes rotates day-to-day.
|
// daily atomic-replace build then deleted every existing mix until
|
||||||
// Score = unskipped-play count + 5 if user has liked the artist.
|
// the user played something again. Same fallback shape as
|
||||||
|
// PickTopPlayedTracksForUser (For You's seeds):
|
||||||
|
//
|
||||||
|
// tier 0 engagement in the last 7 days
|
||||||
|
// tier 1 (only if tier 0 empty) last 30 days
|
||||||
|
// tier 2 (only if tiers 0+1 empty) all-time
|
||||||
|
// tier 3 (only if 0-2 empty) liked artists with no play history
|
||||||
|
//
|
||||||
|
// All returned rows share one tier; produceSeedMixes maps it onto the
|
||||||
|
// rule-#131 pick-kind ladder and stamps the built tracks, so metrics
|
||||||
|
// can compare mixes seeded from fresh vs stale engagement.
|
||||||
|
// Score = unskipped-play count + 5 if user has liked the artist. The
|
||||||
|
// Go-side picker (pickSeedArtistsForDay) shuffles the 12
|
||||||
|
// daily-deterministically and takes songsLikeSeedCount (6) so the mix
|
||||||
|
// set both rotates day-to-day and fills the dedicated Home row (#1491).
|
||||||
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
|
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
|
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -330,7 +387,7 @@ func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]Pi
|
|||||||
var items []PickSeedArtistsRow
|
var items []PickSeedArtistsRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i PickSeedArtistsRow
|
var i PickSeedArtistsRow
|
||||||
if err := rows.Scan(&i.ArtistID, &i.Score); err != nil {
|
if err := rows.Scan(&i.ArtistID, &i.Score, &i.Tier); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -457,10 +514,10 @@ SELECT id
|
|||||||
// tier 2 (only if tiers 0+1 empty) liked tracks
|
// tier 2 (only if tiers 0+1 empty) liked tracks
|
||||||
//
|
//
|
||||||
// Returns up to 5 ids; tie-break by track_id for determinism. The
|
// Returns up to 5 ids; tie-break by track_id for determinism. The
|
||||||
// Go-side picker (pickForYouSeedForDay) rotates one per day via
|
// Go-side picker (pickDailySeeds) draws the day's seeds from these.
|
||||||
// userIDHash. Widened from a hard 7-day window, which made For-You
|
// Widened from a hard 7-day window, which made For-You disappear
|
||||||
// disappear after a week of not listening and never recover on a
|
// after a week of not listening and never recover on a self-hosted
|
||||||
// self-hosted library with sparse history.
|
// library with sparse history.
|
||||||
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
func (q *Queries) PickTopPlayedTracksForUser(ctx context.Context, userID pgtype.UUID) ([]pgtype.UUID, error) {
|
||||||
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
|
rows, err := q.db.Query(ctx, pickTopPlayedTracksForUser, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: tag_settings.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bumpTagSourcesVersion = `-- name: BumpTagSourcesVersion :one
|
||||||
|
UPDATE tag_sources_meta
|
||||||
|
SET current_version = current_version + 1
|
||||||
|
WHERE id = true
|
||||||
|
RETURNING current_version
|
||||||
|
`
|
||||||
|
|
||||||
|
// Increment + return the new version. Called only when the enabled set
|
||||||
|
// changes (not on key edits).
|
||||||
|
func (q *Queries) BumpTagSourcesVersion(ctx context.Context) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, bumpTagSourcesVersion)
|
||||||
|
var current_version int32
|
||||||
|
err := row.Scan(¤t_version)
|
||||||
|
return current_version, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const bumpTagVersionAndSetProvidersHash = `-- name: BumpTagVersionAndSetProvidersHash :one
|
||||||
|
UPDATE tag_sources_meta
|
||||||
|
SET current_version = current_version + 1,
|
||||||
|
last_registered_providers_hash = $1
|
||||||
|
WHERE id = true
|
||||||
|
RETURNING current_version
|
||||||
|
`
|
||||||
|
|
||||||
|
// Atomically increment the version and store the new registered-provider
|
||||||
|
// hash. Called at boot when the compiled-in provider set changed, so
|
||||||
|
// 'none' rows become eligible for a retry through the new chain.
|
||||||
|
func (q *Queries) BumpTagVersionAndSetProvidersHash(ctx context.Context, lastRegisteredProvidersHash string) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, bumpTagVersionAndSetProvidersHash, lastRegisteredProvidersHash)
|
||||||
|
var current_version int32
|
||||||
|
err := row.Scan(¤t_version)
|
||||||
|
return current_version, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentTagSourcesVersion = `-- name: GetCurrentTagSourcesVersion :one
|
||||||
|
SELECT current_version FROM tag_sources_meta WHERE id = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetCurrentTagSourcesVersion(ctx context.Context) (int32, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getCurrentTagSourcesVersion)
|
||||||
|
var current_version int32
|
||||||
|
err := row.Scan(¤t_version)
|
||||||
|
return current_version, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTagProvidersHash = `-- name: GetTagProvidersHash :one
|
||||||
|
SELECT last_registered_providers_hash
|
||||||
|
FROM tag_sources_meta
|
||||||
|
WHERE id = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func (q *Queries) GetTagProvidersHash(ctx context.Context) (string, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getTagProvidersHash)
|
||||||
|
var last_registered_providers_hash string
|
||||||
|
err := row.Scan(&last_registered_providers_hash)
|
||||||
|
return last_registered_providers_hash, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTagProviderSettings = `-- name: ListTagProviderSettings :many
|
||||||
|
|
||||||
|
SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
|
||||||
|
FROM tag_provider_settings
|
||||||
|
ORDER BY display_order, provider_id
|
||||||
|
`
|
||||||
|
|
||||||
|
// Tag-enrichment provider settings queries (milestone #160, #1490 Step 2).
|
||||||
|
// The tags.SettingsService consumes these — parallel to the cover-art
|
||||||
|
// SettingsService (coverart_settings.sql). Keeping the two independent
|
||||||
|
// means a new tag source is added without touching the art settings.
|
||||||
|
// All rows, for boot reconciliation and the admin GET handler.
|
||||||
|
func (q *Queries) ListTagProviderSettings(ctx context.Context) ([]TagProviderSetting, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTagProviderSettings)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []TagProviderSetting
|
||||||
|
for rows.Next() {
|
||||||
|
var i TagProviderSetting
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ProviderID,
|
||||||
|
&i.Enabled,
|
||||||
|
&i.ApiKey,
|
||||||
|
&i.DisplayOrder,
|
||||||
|
&i.CreatedAt,
|
||||||
|
&i.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTagProviderSettings = `-- name: UpdateTagProviderSettings :exec
|
||||||
|
UPDATE tag_provider_settings
|
||||||
|
SET enabled = COALESCE($2, enabled),
|
||||||
|
api_key = CASE WHEN $3::boolean THEN $4 ELSE api_key END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE provider_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpdateTagProviderSettingsParams struct {
|
||||||
|
ProviderID string
|
||||||
|
Enabled bool
|
||||||
|
Column3 bool
|
||||||
|
ApiKey *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin PATCH. $2 sets enabled. Pass FALSE for the $3 apiKeyChanged flag
|
||||||
|
// to leave api_key untouched; TRUE with empty string clears it, TRUE with
|
||||||
|
// a value sets it.
|
||||||
|
func (q *Queries) UpdateTagProviderSettings(ctx context.Context, arg UpdateTagProviderSettingsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, updateTagProviderSettings,
|
||||||
|
arg.ProviderID,
|
||||||
|
arg.Enabled,
|
||||||
|
arg.Column3,
|
||||||
|
arg.ApiKey,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertTagProviderSettings = `-- name: UpsertTagProviderSettings :exec
|
||||||
|
INSERT INTO tag_provider_settings (provider_id, enabled, display_order)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (provider_id) DO UPDATE
|
||||||
|
SET display_order = EXCLUDED.display_order,
|
||||||
|
updated_at = now()
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertTagProviderSettingsParams struct {
|
||||||
|
ProviderID string
|
||||||
|
Enabled bool
|
||||||
|
DisplayOrder int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// INSERT a default row for a newly-registered provider, or refresh an
|
||||||
|
// existing row's display_order on boot. Does NOT overwrite enabled /
|
||||||
|
// api_key on conflict — the operator's settings persist across restarts.
|
||||||
|
func (q *Queries) UpsertTagProviderSettings(ctx context.Context, arg UpsertTagProviderSettingsParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, upsertTagProviderSettings, arg.ProviderID, arg.Enabled, arg.DisplayOrder)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -87,19 +87,21 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
||||||
SELECT t.artist_id, t.genre
|
SELECT t.id AS track_id, t.artist_id, t.genre
|
||||||
FROM general_likes gl
|
FROM general_likes gl
|
||||||
JOIN tracks t ON t.id = gl.track_id
|
JOIN tracks t ON t.id = gl.track_id
|
||||||
WHERE gl.user_id = $1
|
WHERE gl.user_id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListLikedTrackTasteInputsForUserRow struct {
|
type ListLikedTrackTasteInputsForUserRow struct {
|
||||||
|
TrackID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
Genre *string
|
Genre *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// (artist_id, genre) for each track the user has explicitly liked. Feeds the
|
// (track_id, artist_id, genre) for each track the user has explicitly
|
||||||
// track-like bonus into the liked track's artist and tags.
|
// liked. Feeds the track-like bonus into the liked track's artist and
|
||||||
|
// tags; track_id keys the enriched track_tags lookup (#1490).
|
||||||
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,7 +111,7 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
|||||||
var items []ListLikedTrackTasteInputsForUserRow
|
var items []ListLikedTrackTasteInputsForUserRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListLikedTrackTasteInputsForUserRow
|
var i ListLikedTrackTasteInputsForUserRow
|
||||||
if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil {
|
if err := rows.Scan(&i.TrackID, &i.ArtistID, &i.Genre); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -123,6 +125,7 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
|||||||
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
|
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
|
t.id AS track_id,
|
||||||
t.artist_id,
|
t.artist_id,
|
||||||
t.genre,
|
t.genre,
|
||||||
LEAST(GREATEST(
|
LEAST(GREATEST(
|
||||||
@@ -146,6 +149,7 @@ type ListPlayEngagementInputsForUserParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListPlayEngagementInputsForUserRow struct {
|
type ListPlayEngagementInputsForUserRow struct {
|
||||||
|
TrackID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
Genre *string
|
Genre *string
|
||||||
Completion float64
|
Completion float64
|
||||||
@@ -169,6 +173,7 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListPlayEngagementInputsForUserRow
|
var i ListPlayEngagementInputsForUserRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
|
&i.TrackID,
|
||||||
&i.ArtistID,
|
&i.ArtistID,
|
||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.Completion,
|
&i.Completion,
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: track_tags.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deleteTrackTags = `-- name: DeleteTrackTags :exec
|
||||||
|
DELETE FROM track_tags WHERE track_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Clear a track's cached tags before rewriting (atomic replace by the caller).
|
||||||
|
func (q *Queries) DeleteTrackTags(ctx context.Context, trackID pgtype.UUID) error {
|
||||||
|
_, err := q.db.Exec(ctx, deleteTrackTags, trackID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const insertTrackTag = `-- name: InsertTrackTag :exec
|
||||||
|
INSERT INTO track_tags (track_id, tag, weight)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (track_id, tag)
|
||||||
|
DO UPDATE SET weight = GREATEST(track_tags.weight, EXCLUDED.weight)
|
||||||
|
`
|
||||||
|
|
||||||
|
type InsertTrackTagParams struct {
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
Tag string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert one (track, tag); keep the stronger weight when two providers
|
||||||
|
// agree on a tag with different folksonomy strengths.
|
||||||
|
func (q *Queries) InsertTrackTag(ctx context.Context, arg InsertTrackTagParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, insertTrackTag, arg.TrackID, arg.Tag, arg.Weight)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
const listLikedTrackTagsForUser = `-- name: ListLikedTrackTagsForUser :many
|
||||||
|
SELECT tt.track_id, tt.tag, tt.weight
|
||||||
|
FROM track_tags tt
|
||||||
|
JOIN general_likes gl ON gl.track_id = tt.track_id
|
||||||
|
WHERE gl.user_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// Enriched tags for the user's liked tracks — the like-bonus path in the
|
||||||
|
// recompute mirrors the play path, so it needs the same tag lookup.
|
||||||
|
func (q *Queries) ListLikedTrackTagsForUser(ctx context.Context, userID pgtype.UUID) ([]TrackTag, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listLikedTrackTagsForUser, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []TrackTag
|
||||||
|
for rows.Next() {
|
||||||
|
var i TrackTag
|
||||||
|
if err := rows.Scan(&i.TrackID, &i.Tag, &i.Weight); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listPlayedTrackTagsForUser = `-- name: ListPlayedTrackTagsForUser :many
|
||||||
|
SELECT tt.track_id, tt.tag, tt.weight
|
||||||
|
FROM track_tags tt
|
||||||
|
WHERE tt.track_id IN (
|
||||||
|
SELECT DISTINCT pe.track_id
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.was_skipped = false
|
||||||
|
AND pe.started_at > now() - make_interval(days => $2::int)
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListPlayedTrackTagsForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Column2 int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// The enriched tags for every track this user played inside the taste
|
||||||
|
// window, so the recompute can union them into the tag facet alongside
|
||||||
|
// ID3 genre. $1 = user_id, $2 = window_days. One row per (track, tag).
|
||||||
|
func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayedTrackTagsForUserParams) ([]TrackTag, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listPlayedTrackTagsForUser, arg.UserID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []TrackTag
|
||||||
|
for rows.Next() {
|
||||||
|
var i TrackTag
|
||||||
|
if err := rows.Scan(&i.TrackID, &i.Tag, &i.Weight); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const listTracksMissingTags = `-- name: ListTracksMissingTags :many
|
||||||
|
|
||||||
|
SELECT t.id, t.mbid, t.title, a.name AS artist_name
|
||||||
|
FROM tracks t
|
||||||
|
JOIN artists a ON a.id = t.artist_id
|
||||||
|
WHERE t.tag_source IS NULL
|
||||||
|
OR (t.tag_source = 'none' AND t.tag_sources_version < $1)
|
||||||
|
ORDER BY t.id
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListTracksMissingTagsParams struct {
|
||||||
|
TagSourcesVersion int32
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListTracksMissingTagsRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
Mbid *string
|
||||||
|
Title string
|
||||||
|
ArtistName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track-tag enrichment queries (milestone #160, #1490). The tag enricher
|
||||||
|
// drains ListTracksMissingTags, fetches from the provider chain, and
|
||||||
|
// writes the merged top-K via DeleteTrackTags + InsertTrackTag, then
|
||||||
|
// stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape.
|
||||||
|
// Tracks eligible for tag enrichment: never processed (tag_source NULL)
|
||||||
|
// or previously settled 'none' under an older provider version. Returns
|
||||||
|
// the fields the provider chain needs — recording MBID (nullable) for
|
||||||
|
// keyed lookups, plus title + artist name for name-based fallback.
|
||||||
|
// $1 = current tag_sources_version, $2 = limit.
|
||||||
|
func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTracksMissingTags, arg.TagSourcesVersion, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTracksMissingTagsRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTracksMissingTagsRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.ID,
|
||||||
|
&i.Mbid,
|
||||||
|
&i.Title,
|
||||||
|
&i.ArtistName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTrackTagSource = `-- name: SetTrackTagSource :exec
|
||||||
|
UPDATE tracks SET tag_source = $2, tag_sources_version = $3 WHERE id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
type SetTrackTagSourceParams struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
TagSource *string
|
||||||
|
TagSourcesVersion int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp the enrichment outcome so the batch drainer skips settled rows.
|
||||||
|
// $2 = 'lastfm' | 'musicbrainz' | 'mixed' | 'none', $3 = current version.
|
||||||
|
func (q *Queries) SetTrackTagSource(ctx context.Context, arg SetTrackTagSourceParams) error {
|
||||||
|
_, err := q.db.Exec(ctx, setTrackTagSource, arg.ID, arg.TagSource, arg.TagSourcesVersion)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackR
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTrackByID = `-- name: GetTrackByID :one
|
const getTrackByID = `-- name: GetTrackByID :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
||||||
@@ -112,12 +112,14 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTrackByPath = `-- name: GetTrackByPath :one
|
const getTrackByPath = `-- name: GetTrackByPath :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE file_path = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE file_path = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
||||||
@@ -139,12 +141,14 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = ANY($1::uuid[])
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = ANY($1::uuid[])
|
||||||
`
|
`
|
||||||
|
|
||||||
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
||||||
@@ -174,6 +178,8 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -186,7 +192,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -242,6 +248,8 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -256,7 +264,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -309,6 +317,8 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -323,7 +333,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
|
||||||
WHERE album_id = $1
|
WHERE album_id = $1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -365,6 +375,8 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -412,7 +424,7 @@ func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchTracks = `-- name: SearchTracks :many
|
const searchTracks = `-- name: SearchTracks :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
|
||||||
WHERE title ILIKE '%' || $1::text || '%'
|
WHERE title ILIKE '%' || $1::text || '%'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -461,6 +473,8 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -508,7 +522,7 @@ ON CONFLICT (file_path) DO UPDATE SET
|
|||||||
mbid = EXCLUDED.mbid,
|
mbid = EXCLUDED.mbid,
|
||||||
genre = EXCLUDED.genre,
|
genre = EXCLUDED.genre,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at
|
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertTrackParams struct {
|
type UpsertTrackParams struct {
|
||||||
@@ -559,6 +573,8 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE play_events DROP COLUMN pick_kind;
|
||||||
|
ALTER TABLE playlist_tracks DROP COLUMN pick_kind;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- For You exploration attribution (milestone #127 step 2, Scribe #1249).
|
||||||
|
--
|
||||||
|
-- For You is deliberately composed of two populations: a head of
|
||||||
|
-- top-scored similarity matches ("taste" picks) and a tail sampled from
|
||||||
|
-- deeper in the candidate ranking ("fresh" picks — the freshness
|
||||||
|
-- injection). Until now the metrics could only judge For You as one
|
||||||
|
-- blob, so a high skip rate couldn't be read as "the taste engine is
|
||||||
|
-- missing" vs "the freshness tax is too high".
|
||||||
|
--
|
||||||
|
-- Two denormalized columns, both nullable:
|
||||||
|
--
|
||||||
|
-- 1. playlist_tracks.pick_kind — stamped by the system-playlist builder
|
||||||
|
-- at snapshot build time. NULL for manual playlists and for system
|
||||||
|
-- variants that don't split (Discover, Songs like X, discovery
|
||||||
|
-- mixes). System snapshots are atomically replaced daily, so the
|
||||||
|
-- column never goes stale within a snapshot.
|
||||||
|
--
|
||||||
|
-- 2. play_events.pick_kind — stamped at play-ingestion time by looking
|
||||||
|
-- the track up in the user's CURRENT For You snapshot. Attribution
|
||||||
|
-- must be frozen at play time: For You rebuilds daily, so a 30-day
|
||||||
|
-- metrics window spans ~30 snapshots and a join-at-read against the
|
||||||
|
-- live snapshot would misattribute nearly everything. Plays that
|
||||||
|
-- predate this feature (or whose track already rotated out, e.g. a
|
||||||
|
-- late offline replay) stay NULL → reported as unattributed.
|
||||||
|
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
ADD COLUMN pick_kind text
|
||||||
|
CONSTRAINT playlist_tracks_pick_kind_check
|
||||||
|
CHECK (pick_kind IN ('taste', 'fresh'));
|
||||||
|
|
||||||
|
ALTER TABLE play_events
|
||||||
|
ADD COLUMN pick_kind text
|
||||||
|
CONSTRAINT play_events_pick_kind_check
|
||||||
|
CHECK (pick_kind IN ('taste', 'fresh'));
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Rows stamped with the widened vocabulary must be cleared before the
|
||||||
|
-- narrow CHECK can be re-added; NULL reads as "unattributed", which is
|
||||||
|
-- the honest downgrade.
|
||||||
|
UPDATE play_events SET pick_kind = NULL
|
||||||
|
WHERE pick_kind NOT IN ('taste', 'fresh');
|
||||||
|
UPDATE playlist_tracks SET pick_kind = NULL
|
||||||
|
WHERE pick_kind NOT IN ('taste', 'fresh');
|
||||||
|
|
||||||
|
ALTER TABLE play_events
|
||||||
|
DROP CONSTRAINT play_events_pick_kind_check;
|
||||||
|
ALTER TABLE play_events
|
||||||
|
ADD CONSTRAINT play_events_pick_kind_check
|
||||||
|
CHECK (pick_kind IN ('taste', 'fresh'));
|
||||||
|
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
DROP CONSTRAINT playlist_tracks_pick_kind_check;
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
ADD CONSTRAINT playlist_tracks_pick_kind_check
|
||||||
|
CHECK (pick_kind IN ('taste', 'fresh'));
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Provenance as standard (milestone #127, Scribe #1270).
|
||||||
|
--
|
||||||
|
-- #1249 introduced pick_kind as a For You one-off ('taste'/'fresh').
|
||||||
|
-- The mechanism — stamp WHY a track is in the snapshot at build time,
|
||||||
|
-- freeze it onto the play at ingestion, break it down in metrics — is
|
||||||
|
-- now the standard for every system mix, so the CHECK vocabulary
|
||||||
|
-- widens to cover:
|
||||||
|
--
|
||||||
|
-- taste / fresh For You head vs freshness-injection tail
|
||||||
|
-- dormant / cross_user / random
|
||||||
|
-- Discover's three candidate buckets — makes
|
||||||
|
-- the 40/30/30 allocation measurable instead
|
||||||
|
-- of a guess
|
||||||
|
-- tier1 / tier2 / tier3 the tiered-eligibility ladder (project rule
|
||||||
|
-- #131): tier 1 pins the mix's exact desire,
|
||||||
|
-- each higher tier steps back a little. Tier
|
||||||
|
-- provenance is how we measure what the
|
||||||
|
-- step-back trades away.
|
||||||
|
--
|
||||||
|
-- Postgres CHECK whitelists can't be altered in place: DROP + re-ADD
|
||||||
|
-- with the expanded list, in the same migration.
|
||||||
|
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
DROP CONSTRAINT playlist_tracks_pick_kind_check;
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
ADD CONSTRAINT playlist_tracks_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE play_events
|
||||||
|
DROP CONSTRAINT play_events_pick_kind_check;
|
||||||
|
ALTER TABLE play_events
|
||||||
|
ADD CONSTRAINT play_events_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
DROP TABLE recommendation_tuning_audit;
|
||||||
|
DROP TABLE taste_tuning;
|
||||||
|
DROP TABLE recommendation_weight_profiles;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Recommendation tuning lab (milestone #127, Scribe #1250).
|
||||||
|
--
|
||||||
|
-- The scoring weights move out of YAML (radio profile) and out of the
|
||||||
|
-- systemMixWeights hard-code (daily_mix profile) into DB-backed
|
||||||
|
-- settings with live effect — the defaults-discovery lab (decision
|
||||||
|
-- #1247): the operator turns knobs here to FIND good values, which
|
||||||
|
-- then get baked into shipped defaults; end users and other operators
|
||||||
|
-- should never need this card.
|
||||||
|
--
|
||||||
|
-- Rows are seeded by the recsettings service's boot reconcile (the
|
||||||
|
-- coverart SettingsService pattern), not by this migration, so shipped
|
||||||
|
-- defaults live in exactly one place (Go).
|
||||||
|
|
||||||
|
CREATE TABLE recommendation_weight_profiles (
|
||||||
|
profile text PRIMARY KEY
|
||||||
|
CONSTRAINT recommendation_weight_profiles_profile_check
|
||||||
|
CHECK (profile IN ('radio', 'daily_mix')),
|
||||||
|
base_weight double precision NOT NULL,
|
||||||
|
like_boost double precision NOT NULL,
|
||||||
|
recency_weight double precision NOT NULL,
|
||||||
|
skip_penalty double precision NOT NULL,
|
||||||
|
jitter_magnitude double precision NOT NULL,
|
||||||
|
context_weight double precision NOT NULL,
|
||||||
|
similarity_weight double precision NOT NULL,
|
||||||
|
taste_weight double precision NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Taste-profile build knobs: engagement half-life + the completion→
|
||||||
|
-- engagement curve points (taste.Config's tunable subset).
|
||||||
|
CREATE TABLE taste_tuning (
|
||||||
|
singleton boolean PRIMARY KEY DEFAULT true
|
||||||
|
CONSTRAINT taste_tuning_singleton_check CHECK (singleton),
|
||||||
|
half_life_days double precision NOT NULL,
|
||||||
|
engagement_hard_skip double precision NOT NULL,
|
||||||
|
engagement_neutral double precision NOT NULL,
|
||||||
|
engagement_full double precision NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Every knob turn writes one audit row; the metrics trend view
|
||||||
|
-- (#1251) annotates these on its timeline so cause→effect is visible
|
||||||
|
-- after a change. changes is a jsonb array of {field, old, new}.
|
||||||
|
CREATE TABLE recommendation_tuning_audit (
|
||||||
|
id bigserial PRIMARY KEY,
|
||||||
|
changed_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
scope text NOT NULL
|
||||||
|
CONSTRAINT recommendation_tuning_audit_scope_check
|
||||||
|
CHECK (scope IN ('radio', 'daily_mix', 'taste')),
|
||||||
|
action text NOT NULL
|
||||||
|
CONSTRAINT recommendation_tuning_audit_action_check
|
||||||
|
CHECK (action IN ('update', 'reset')),
|
||||||
|
changes jsonb NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX recommendation_tuning_audit_changed_at_idx
|
||||||
|
ON recommendation_tuning_audit (changed_at DESC);
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Reverse 0041_discover_taste_unheard.up.sql.
|
||||||
|
--
|
||||||
|
-- Null out any rows stamped with the new value before shrinking the
|
||||||
|
-- CHECK back to the 0039 vocabulary, or the ADD CONSTRAINT would fail
|
||||||
|
-- on existing 'taste_unheard' rows (mirrors 0039's down).
|
||||||
|
UPDATE play_events SET pick_kind = NULL WHERE pick_kind = 'taste_unheard';
|
||||||
|
UPDATE playlist_tracks SET pick_kind = NULL WHERE pick_kind = 'taste_unheard';
|
||||||
|
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
DROP CONSTRAINT playlist_tracks_pick_kind_check;
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
ADD CONSTRAINT playlist_tracks_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE play_events
|
||||||
|
DROP CONSTRAINT play_events_pick_kind_check;
|
||||||
|
ALTER TABLE play_events
|
||||||
|
ADD CONSTRAINT play_events_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Discover taste-targeted novelty bucket (milestone #127, Scribe #1252/#1488).
|
||||||
|
--
|
||||||
|
-- The 2-week metrics review found Discover beating the manual baseline on
|
||||||
|
-- skip rate — which for a discovery surface means it is playing it safe:
|
||||||
|
-- on a single-user server it is effectively dormant + crude-random, and
|
||||||
|
-- the per-user taste profile (n_tags, #796) went unused. This adds a
|
||||||
|
-- fourth Discover candidate bucket that ranks unheard tracks by the
|
||||||
|
-- user's taste-profile tag weights ("novelty that fits your vibe"), so
|
||||||
|
-- its picks need a new pick_kind provenance value.
|
||||||
|
--
|
||||||
|
-- Postgres CHECK whitelists can't be altered in place: DROP + re-ADD with
|
||||||
|
-- the expanded list, in the same migration (family rule — new CHECK-enum
|
||||||
|
-- value needs a same-change migration).
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
DROP CONSTRAINT playlist_tracks_pick_kind_check;
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
ADD CONSTRAINT playlist_tracks_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random', 'taste_unheard',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE play_events
|
||||||
|
DROP CONSTRAINT play_events_pick_kind_check;
|
||||||
|
ALTER TABLE play_events
|
||||||
|
ADD CONSTRAINT play_events_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random', 'taste_unheard',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Reverse 0042_track_tags.up.sql.
|
||||||
|
DROP INDEX IF EXISTS tracks_tag_source_idx;
|
||||||
|
ALTER TABLE tracks DROP COLUMN IF EXISTS tag_sources_version;
|
||||||
|
ALTER TABLE tracks DROP COLUMN IF EXISTS tag_source;
|
||||||
|
DROP TABLE IF EXISTS track_tags;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Track-level folksonomy tags for taste enrichment (milestone #160, #1490).
|
||||||
|
--
|
||||||
|
-- A global (not per-user) cache of style/mood tags fetched from
|
||||||
|
-- MusicBrainz (keyless) + Last.fm (when a key is configured), keyed by
|
||||||
|
-- track. Feeds the taste recompute's tag facet alongside raw ID3 genre,
|
||||||
|
-- lifting resolution from coarse "Rock" to "post-punk / shoegaze /
|
||||||
|
-- melancholic". The enricher caps to the top-K tags per track at write
|
||||||
|
-- time so a heavily-tagged track can't dominate the profile. weight is a
|
||||||
|
-- normalized folksonomy strength in [0,1]. Mirrors the coverart
|
||||||
|
-- enricher's versioned source-tracking pattern.
|
||||||
|
CREATE TABLE track_tags (
|
||||||
|
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
tag text NOT NULL,
|
||||||
|
weight double precision NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (track_id, tag)
|
||||||
|
);
|
||||||
|
CREATE INDEX track_tags_track_idx ON track_tags (track_id);
|
||||||
|
|
||||||
|
-- Enrichment bookkeeping (mirror artists.artist_art_source):
|
||||||
|
-- tag_source NULL → not yet processed (eligible)
|
||||||
|
-- tag_source 'lastfm' / 'musicbrainz' / 'mixed' → found, cached
|
||||||
|
-- tag_source 'none' → providers returned nothing
|
||||||
|
-- tag_sources_version → bump to re-process none/stale
|
||||||
|
ALTER TABLE tracks ADD COLUMN tag_source text;
|
||||||
|
ALTER TABLE tracks ADD COLUMN tag_sources_version integer NOT NULL DEFAULT 0;
|
||||||
|
CREATE INDEX tracks_tag_source_idx ON tracks (tag_source, tag_sources_version);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Reverse 0043_tag_provider_settings.up.sql.
|
||||||
|
DROP TABLE IF EXISTS tag_sources_meta;
|
||||||
|
DROP TABLE IF EXISTS tag_provider_settings;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Tag-enrichment provider settings (milestone #160, #1490 Step 2).
|
||||||
|
--
|
||||||
|
-- Mirrors the cover-art provider-settings substrate (migration 0018) so
|
||||||
|
-- adding a new tag source later is "register a provider + it auto-upserts
|
||||||
|
-- a row here" — never a schema change. The tag enricher reads the enabled
|
||||||
|
-- set + api keys from tag_provider_settings and stamps the current version
|
||||||
|
-- from tag_sources_meta onto tracks.tag_sources_version (added in 0042).
|
||||||
|
--
|
||||||
|
-- current_version starts at 1 while tracks.tag_sources_version defaults to
|
||||||
|
-- 0 (migration 0042), so every existing track is "stale" relative to
|
||||||
|
-- current and gets enriched on the first pass after this migrates.
|
||||||
|
|
||||||
|
CREATE TABLE tag_provider_settings (
|
||||||
|
provider_id text PRIMARY KEY,
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
api_key text,
|
||||||
|
display_order int NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tag_sources_meta (
|
||||||
|
id boolean PRIMARY KEY DEFAULT true,
|
||||||
|
current_version int NOT NULL DEFAULT 1,
|
||||||
|
last_registered_providers_hash text NOT NULL DEFAULT '',
|
||||||
|
CONSTRAINT tag_sources_meta_singleton CHECK (id = true)
|
||||||
|
);
|
||||||
|
INSERT INTO tag_sources_meta (id, current_version) VALUES (true, 1);
|
||||||
|
|
||||||
|
-- Seed the v1 providers. MusicBrainz is the keyless always-on default;
|
||||||
|
-- Last.fm ships disabled and is opt-in once the operator supplies a key
|
||||||
|
-- (Step 4 Settings UI), per the no-coercive-settings rule (#26). The
|
||||||
|
-- SettingsService also upserts these at boot, so the seed is belt-and-
|
||||||
|
-- suspenders / documents the intended defaults.
|
||||||
|
INSERT INTO tag_provider_settings (provider_id, enabled, display_order) VALUES
|
||||||
|
('musicbrainz', true, 0),
|
||||||
|
('lastfm', false, 1);
|
||||||
@@ -102,3 +102,37 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
)
|
)
|
||||||
ORDER BY md5(t.id::text || $2::text)
|
ORDER BY md5(t.id::text || $2::text)
|
||||||
LIMIT 200;
|
LIMIT 200;
|
||||||
|
|
||||||
|
-- name: ListTasteUnheardTracksForDiscover :many
|
||||||
|
-- Taste-targeted novelty: unheard tracks whose genres overlap the user's
|
||||||
|
-- taste-profile tags (taste_profile_tags, #796), ranked by summed tag
|
||||||
|
-- weight — "new to you, but your vibe" rather than the crude random arm.
|
||||||
|
-- Genres live inline on tracks.genre as a delimited string, split the
|
||||||
|
-- same way the radio tag_overlap arm does (regexp_split_to_table on
|
||||||
|
-- [;,]). Same exclusion filters as the other buckets. Returns nothing
|
||||||
|
-- when the user has no taste tags yet (cold start), so the caller
|
||||||
|
-- redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
||||||
|
-- $1 = user_id, $2 = date string for md5 tiebreak ordering.
|
||||||
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
|
FROM tracks t
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
|
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||||
|
WHERE nt.weight > 0
|
||||||
|
AND trim(g_split.g) <> ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.track_id = t.id
|
||||||
|
AND pe.was_skipped = false
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM general_likes gl
|
||||||
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
GROUP BY t.id, t.album_id, t.artist_id
|
||||||
|
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
||||||
|
LIMIT 120;
|
||||||
|
|||||||
@@ -24,11 +24,32 @@ ORDER BY started_at DESC
|
|||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: InsertPlayEvent :one
|
-- name: InsertPlayEvent :one
|
||||||
|
-- pick_kind is non-NULL only for system-playlist plays whose track was
|
||||||
|
-- found (with a stamped kind) in the user's live snapshot for that
|
||||||
|
-- variant at ingestion time (#1249, generalized in #1270).
|
||||||
INSERT INTO play_events (
|
INSERT INTO play_events (
|
||||||
user_id, track_id, session_id, started_at, client_id, source
|
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetSystemPickKindForTrack :one
|
||||||
|
-- Looks a track up in the user's CURRENT snapshot of the given system
|
||||||
|
-- variant and returns its pick_kind. Used at play-ingestion time to
|
||||||
|
-- freeze provenance onto the play_event — snapshots rebuild daily, so
|
||||||
|
-- attribution can't be reconstructed at read time (#1249/#1270). No
|
||||||
|
-- row = track not in today's snapshot (caller stores NULL); a row with
|
||||||
|
-- NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
|
||||||
|
-- is non-singleton (up to 3 mixes/user); a track in two of them takes
|
||||||
|
-- whichever LIMIT 1 hits — acceptable, tier stamps there describe the
|
||||||
|
-- same eligibility ladder.
|
||||||
|
SELECT pt.pick_kind
|
||||||
|
FROM playlist_tracks pt
|
||||||
|
JOIN playlists p ON p.id = pt.playlist_id
|
||||||
|
WHERE p.user_id = $1
|
||||||
|
AND p.system_variant = $2
|
||||||
|
AND pt.track_id = $3
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: UpdatePlayEventEnded :one
|
-- name: UpdatePlayEventEnded :one
|
||||||
-- Closes a play_event by id with the given ended_at, duration, and skip flag.
|
-- Closes a play_event by id with the given ended_at, duration, and skip flag.
|
||||||
-- completion_ratio is computed from duration_played_ms and the track duration
|
-- completion_ratio is computed from duration_played_ms and the track duration
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ ORDER BY pt.position;
|
|||||||
-- name: AppendPlaylistTrack :one
|
-- name: AppendPlaylistTrack :one
|
||||||
-- Inserts at the next available position. Snapshot fields are copied
|
-- Inserts at the next available position. Snapshot fields are copied
|
||||||
-- from the tracks/albums/artists join at insert time. tracks.duration_ms
|
-- from the tracks/albums/artists join at insert time. tracks.duration_ms
|
||||||
-- is converted to seconds for the snapshot.
|
-- is converted to seconds for the snapshot. pick_kind is NULL for manual
|
||||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
|
-- playlists; the For You builder stamps 'taste'/'fresh' (#1249).
|
||||||
|
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
|
||||||
SELECT
|
SELECT
|
||||||
sqlc.arg(playlist_id)::uuid,
|
sqlc.arg(playlist_id)::uuid,
|
||||||
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0),
|
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0),
|
||||||
@@ -77,7 +78,8 @@ SELECT
|
|||||||
t.title,
|
t.title,
|
||||||
artists.name,
|
artists.name,
|
||||||
albums.title,
|
albums.title,
|
||||||
(t.duration_ms / 1000)::integer
|
(t.duration_ms / 1000)::integer,
|
||||||
|
sqlc.narg(pick_kind)::text
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums ON albums.id = t.album_id
|
JOIN albums ON albums.id = t.album_id
|
||||||
JOIN artists ON artists.id = t.artist_id
|
JOIN artists ON artists.id = t.artist_id
|
||||||
|
|||||||
@@ -1,22 +1,58 @@
|
|||||||
-- Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
-- Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||||
-- operator can see whether each recommendation surface is landing and tune 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
|
-- taste weights. Source is stamped on play_events when a play is launched from
|
||||||
-- a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
-- a recommendation surface; NULL means the user picked the track manually —
|
||||||
-- NULL for library / radio / user-playlist plays, which are excluded here.
|
-- those rows are INCLUDED here as the baseline control group the surfaces are
|
||||||
|
-- judged against (milestone #127: delta-vs-baseline is what makes the numbers
|
||||||
|
-- actionable). Raw source strings are bucketed into stable surface families in
|
||||||
|
-- the Go handler; completion_n is carried so family merges can weight
|
||||||
|
-- avg_completion correctly.
|
||||||
|
|
||||||
-- name: RecommendationSourceMetricsForUser :many
|
-- name: RecommendationWeeklyTrends :many
|
||||||
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
-- Weekly per-source outcome series for the tuning lab's trend view
|
||||||
-- mean completion ratio over plays that recorded one (0 when none did).
|
-- (#1251). Aggregated across ALL users: the tuning knobs are global,
|
||||||
|
-- so judging a knob turn needs global outcomes — rows carry rates
|
||||||
|
-- only, no track or user identity. NULL-source (manual) rows are
|
||||||
|
-- included as the baseline family.
|
||||||
|
--
|
||||||
|
-- taste_hits counts plays whose track's artist has a positive weight
|
||||||
|
-- in that user's CURRENT taste profile — the "cheap recompute" option:
|
||||||
|
-- retroactive over the whole window, at the cost of drift (the profile
|
||||||
|
-- is today's, the play may be weeks old). Good enough to read whether
|
||||||
|
-- a surface is feeding taste-fitting tracks.
|
||||||
|
-- $1 window in weeks.
|
||||||
SELECT
|
SELECT
|
||||||
|
date_trunc('week', pe.started_at)::date AS week_start,
|
||||||
pe.source,
|
pe.source,
|
||||||
count(*)::bigint AS plays,
|
count(*)::bigint AS plays,
|
||||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
COALESCE(
|
count(pe.completion_ratio)::bigint AS completion_n,
|
||||||
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion,
|
||||||
0)::float8 AS avg_completion
|
count(*) FILTER (WHERE tpa.artist_id IS NOT NULL)::bigint AS taste_hits
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
LEFT JOIN taste_profile_artists tpa
|
||||||
|
ON tpa.user_id = pe.user_id
|
||||||
|
AND tpa.artist_id = t.artist_id
|
||||||
|
AND tpa.weight > 0
|
||||||
|
WHERE pe.started_at > now() - (sqlc.arg(weeks)::int * INTERVAL '1 week')
|
||||||
|
GROUP BY 1, 2
|
||||||
|
ORDER BY 1, 2;
|
||||||
|
|
||||||
|
-- name: RecommendationSourceMetricsForUser :many
|
||||||
|
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||||
|
-- mean completion ratio over the completion_n plays that recorded one.
|
||||||
|
-- pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
||||||
|
-- it is NULL for every other source, so those still group to one row.
|
||||||
|
SELECT
|
||||||
|
pe.source,
|
||||||
|
pe.pick_kind,
|
||||||
|
count(*)::bigint AS plays,
|
||||||
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
|
count(pe.completion_ratio)::bigint AS completion_n,
|
||||||
|
COALESCE(avg(pe.completion_ratio), 0)::float8 AS avg_completion
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.source IS NOT NULL
|
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
GROUP BY pe.source
|
GROUP BY pe.source, pe.pick_kind
|
||||||
ORDER BY plays DESC;
|
ORDER BY plays DESC;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- Recommendation tuning lab queries (#1250). Seeding happens via the
|
||||||
|
-- recsettings boot reconcile; shipped defaults live in Go only.
|
||||||
|
|
||||||
|
-- name: UpsertWeightProfileDefaults :exec
|
||||||
|
-- Boot reconcile: insert the shipped defaults for a profile if the row
|
||||||
|
-- doesn't exist yet. Never overwrites operator-tuned values.
|
||||||
|
INSERT INTO recommendation_weight_profiles (
|
||||||
|
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||||
|
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
ON CONFLICT (profile) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: ListWeightProfiles :many
|
||||||
|
SELECT * FROM recommendation_weight_profiles ORDER BY profile;
|
||||||
|
|
||||||
|
-- name: UpdateWeightProfile :one
|
||||||
|
UPDATE recommendation_weight_profiles
|
||||||
|
SET base_weight = $2,
|
||||||
|
like_boost = $3,
|
||||||
|
recency_weight = $4,
|
||||||
|
skip_penalty = $5,
|
||||||
|
jitter_magnitude = $6,
|
||||||
|
context_weight = $7,
|
||||||
|
similarity_weight = $8,
|
||||||
|
taste_weight = $9,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE profile = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpsertTasteTuningDefaults :exec
|
||||||
|
INSERT INTO taste_tuning (
|
||||||
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
|
engagement_neutral, engagement_full
|
||||||
|
) VALUES (true, $1, $2, $3, $4)
|
||||||
|
ON CONFLICT (singleton) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: GetTasteTuning :one
|
||||||
|
SELECT * FROM taste_tuning WHERE singleton = true;
|
||||||
|
|
||||||
|
-- name: UpdateTasteTuning :one
|
||||||
|
UPDATE taste_tuning
|
||||||
|
SET half_life_days = $1,
|
||||||
|
engagement_hard_skip = $2,
|
||||||
|
engagement_neutral = $3,
|
||||||
|
engagement_full = $4,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE singleton = true
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: InsertTuningAudit :exec
|
||||||
|
-- changes is a jsonb array of {field, old, new} objects.
|
||||||
|
INSERT INTO recommendation_tuning_audit (scope, action, changes)
|
||||||
|
VALUES ($1, $2, $3);
|
||||||
|
|
||||||
|
-- name: ListTuningAudit :many
|
||||||
|
-- Newest first; consumed by the metrics trend view (#1251) to annotate
|
||||||
|
-- knob turns on the timeline.
|
||||||
|
SELECT id, changed_at, scope, action, changes
|
||||||
|
FROM recommendation_tuning_audit
|
||||||
|
ORDER BY changed_at DESC, id DESC
|
||||||
|
LIMIT $1;
|
||||||
@@ -8,6 +8,11 @@
|
|||||||
-- name: ListDeepCutsTracks :many
|
-- name: ListDeepCutsTracks :many
|
||||||
-- #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
|
-- #419 Deep Cuts: low-play tracks (<=2 plays) from artists the user
|
||||||
-- has liked OR played heavily (>=5 non-skip plays across the artist).
|
-- has liked OR played heavily (>=5 non-skip plays across the artist).
|
||||||
|
-- Tracks the user has skipped twice or more don't qualify (#1257):
|
||||||
|
-- eligibility used to count only unskipped plays, so the most actively
|
||||||
|
-- rejected tracks read as "barely heard" and kept being re-offered.
|
||||||
|
-- Threshold 2 so a single accidental skip doesn't banish a track;
|
||||||
|
-- passive-signal only (no dislike UI, rule #101).
|
||||||
-- $1 user_id, $2 date string.
|
-- $1 user_id, $2 date string.
|
||||||
WITH affinity_artists AS (
|
WITH affinity_artists AS (
|
||||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
||||||
@@ -23,12 +28,20 @@ play_counts AS (
|
|||||||
FROM play_events
|
FROM play_events
|
||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
|
),
|
||||||
|
skip_counts AS (
|
||||||
|
SELECT track_id, COUNT(*) AS c
|
||||||
|
FROM play_events
|
||||||
|
WHERE user_id = $1 AND was_skipped = true
|
||||||
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
JOIN affinity_artists aa ON aa.artist_id = t.artist_id
|
||||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||||
|
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||||
WHERE COALESCE(pc.c, 0) <= 2
|
WHERE COALESCE(pc.c, 0) <= 2
|
||||||
|
AND COALESCE(sc.c, 0) < 2
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
@@ -37,76 +50,113 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
LIMIT 200;
|
LIMIT 200;
|
||||||
|
|
||||||
-- name: ListRediscoverTracks :many
|
-- name: ListRediscoverTracks :many
|
||||||
-- #420 Rediscover: tracks the user played a lot (>=5 non-skip) but
|
-- #420 Rediscover: tracks the user played a lot but has drifted away
|
||||||
-- has drifted away from. Tiered so a young library still gets a mix:
|
-- from. One blended pool (issue #1246: the old two-tier UNION was
|
||||||
-- tier 0 not played in the last 6 months (true rediscovery)
|
-- all-or-nothing — a single ">=6 months cold" row suppressed the whole
|
||||||
-- tier 1 (only if tier 0 empty) not played in the last 30 days
|
-- ">=30 days" tier, which is how a one-song playlist shipped; the 6mo
|
||||||
-- Ordered by historical affection. $1 user_id.
|
-- tier was also a strict subset of the 30d tier). Eligibility is >=3
|
||||||
|
-- non-skip plays and >=30 days cold; the bar sits at 3 rather than 5
|
||||||
|
-- so a weeks-old library still fields a pool — on young histories the
|
||||||
|
-- >=5-play tracks are precisely the ones still in rotation. Ordering
|
||||||
|
-- prefers true rediscoveries (>=6 months cold), then strong affection
|
||||||
|
-- (>=5 plays), then raw play count; the producer's minimum floor
|
||||||
|
-- decides whether the pool is big enough to ship at all. $1 user_id.
|
||||||
WITH stats AS (
|
WITH stats AS (
|
||||||
SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
|
SELECT pe.track_id, COUNT(*) AS c, MAX(pe.started_at) AS last_at
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
||||||
GROUP BY pe.track_id
|
GROUP BY pe.track_id
|
||||||
),
|
|
||||||
deep AS (
|
|
||||||
SELECT t.id, t.album_id, t.artist_id, s.c, 0 AS tier
|
|
||||||
FROM tracks t
|
|
||||||
JOIN stats s ON s.track_id = t.id
|
|
||||||
WHERE s.c >= 5
|
|
||||||
AND s.last_at <= now() - interval '6 months'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
||||||
)
|
|
||||||
),
|
|
||||||
shallow AS (
|
|
||||||
SELECT t.id, t.album_id, t.artist_id, s.c, 1 AS tier
|
|
||||||
FROM tracks t
|
|
||||||
JOIN stats s ON s.track_id = t.id
|
|
||||||
WHERE s.c >= 5
|
|
||||||
AND s.last_at <= now() - interval '30 days'
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
SELECT id, album_id, artist_id
|
|
||||||
FROM (
|
|
||||||
SELECT id, album_id, artist_id, c, tier FROM deep
|
|
||||||
UNION ALL
|
|
||||||
SELECT id, album_id, artist_id, c, tier FROM shallow
|
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM deep)
|
|
||||||
) u
|
|
||||||
ORDER BY tier, c DESC, id
|
|
||||||
LIMIT 200;
|
|
||||||
|
|
||||||
-- name: ListNewForYouTracks :many
|
|
||||||
-- #421 New for you: tracks from albums added in the last 30 days
|
|
||||||
-- whose artist the user has liked OR played (>=3 non-skip). Album-
|
|
||||||
-- coherent (newest album first, then disc/track) — the producer does
|
|
||||||
-- NOT diversity-cap these; they're meant as whole-album discovery.
|
|
||||||
-- $1 user_id.
|
|
||||||
WITH affinity_artists AS (
|
|
||||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
|
||||||
UNION
|
|
||||||
SELECT t.artist_id
|
|
||||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
|
||||||
WHERE pe.user_id = $1 AND pe.was_skipped = false
|
|
||||||
GROUP BY t.artist_id
|
|
||||||
HAVING COUNT(*) >= 3
|
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN stats s ON s.track_id = t.id
|
||||||
JOIN affinity_artists aa ON aa.artist_id = al.artist_id
|
WHERE s.c >= 3
|
||||||
WHERE al.created_at >= now() - interval '30 days'
|
AND s.last_at <= now() - interval '30 days'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY al.created_at DESC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
-- Declined-resurfacing cooldown (#1258): a skip on a
|
||||||
|
-- rediscover-sourced play is the user explicitly saying "I've moved
|
||||||
|
-- on" to the exact invitation this mix extends — such tracks sit
|
||||||
|
-- out ~90 days instead of re-qualifying the next day. Passive
|
||||||
|
-- signal only (rule #101).
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM play_events rp
|
||||||
|
WHERE rp.user_id = $1
|
||||||
|
AND rp.track_id = t.id
|
||||||
|
AND rp.source = 'rediscover'
|
||||||
|
AND rp.was_skipped = true
|
||||||
|
AND rp.started_at > now() - interval '90 days'
|
||||||
|
)
|
||||||
|
ORDER BY (s.last_at <= now() - interval '6 months') DESC,
|
||||||
|
(s.c >= 5) DESC,
|
||||||
|
s.c DESC, t.id
|
||||||
LIMIT 200;
|
LIMIT 200;
|
||||||
|
|
||||||
|
-- name: ListNewForYouTracks :many
|
||||||
|
-- #421 New for you, tiered per project rule #131 (#1267). The old
|
||||||
|
-- single hard rule (added <30d AND direct affinity) had two failure
|
||||||
|
-- modes with one root: no notion of consumption — the album you
|
||||||
|
-- devoured in week one crowded the mix for three more weeks, and
|
||||||
|
-- after a quiet month nothing qualified at all.
|
||||||
|
--
|
||||||
|
-- "Consumed" = any track attempted for >=30s (the First Listens
|
||||||
|
-- threshold, #1268): once you've meaningfully engaged, the album is
|
||||||
|
-- no longer news. Applies to every tier — played albums leave the
|
||||||
|
-- mix the next build.
|
||||||
|
--
|
||||||
|
-- tier 1 the exact desire: unconsumed albums added <30d by
|
||||||
|
-- direct-affinity artists (liked, or >=3 attempted plays)
|
||||||
|
-- tier 2 step back a little: unconsumed affinity albums from the
|
||||||
|
-- wider 30-90d window — added while you weren't looking
|
||||||
|
-- tier 3 step back more: any unconsumed album added <90d
|
||||||
|
-- regardless of affinity, newest first
|
||||||
|
--
|
||||||
|
-- Album-coherent within tiers (newest album first, then disc/track);
|
||||||
|
-- the producer fills top-down and rotates within tier blocks. The
|
||||||
|
-- tier lands on playlist_tracks.pick_kind so metrics can price what
|
||||||
|
-- each step-back trades away. $1 user_id.
|
||||||
|
WITH attempted AS (
|
||||||
|
SELECT DISTINCT pe.track_id
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
|
||||||
|
),
|
||||||
|
consumed_albums AS (
|
||||||
|
SELECT DISTINCT t.album_id
|
||||||
|
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||||
|
),
|
||||||
|
affinity_artists AS (
|
||||||
|
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT t.artist_id
|
||||||
|
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
HAVING COUNT(*) >= 3
|
||||||
|
),
|
||||||
|
albums_tiered AS (
|
||||||
|
SELECT al.id AS album_id, al.created_at,
|
||||||
|
CASE
|
||||||
|
WHEN al.created_at >= now() - interval '30 days'
|
||||||
|
AND al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 1
|
||||||
|
WHEN al.artist_id IN (SELECT artist_id FROM affinity_artists) THEN 2
|
||||||
|
ELSE 3
|
||||||
|
END AS tier
|
||||||
|
FROM albums al
|
||||||
|
WHERE al.created_at >= now() - interval '90 days'
|
||||||
|
AND al.id NOT IN (SELECT album_id FROM consumed_albums)
|
||||||
|
)
|
||||||
|
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||||
|
FROM tracks t
|
||||||
|
JOIN albums_tiered alt ON alt.album_id = t.album_id
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
ORDER BY alt.tier, alt.created_at DESC, t.album_id,
|
||||||
|
t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||||
|
LIMIT 300;
|
||||||
|
|
||||||
-- name: ListOnThisDayTracks :many
|
-- name: ListOnThisDayTracks :many
|
||||||
-- #422 On This Day: tracks the user played around this calendar date
|
-- #422 On This Day: tracks the user played around this calendar date
|
||||||
-- (±10 day-of-year) in the past, excluding the very recent (>30 days
|
-- (±10 day-of-year) in the past, excluding the very recent (>30 days
|
||||||
@@ -115,13 +165,23 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
-- and window ±7→±10 so it surfaces on a months-old library instead
|
-- and window ±7→±10 so it surfaces on a months-old library instead
|
||||||
-- of needing a full year of history; still skips cleanly (no rows →
|
-- of needing a full year of history; still skips cleanly (no rows →
|
||||||
-- no playlist) when there's no qualifying history yet.
|
-- no playlist) when there's no qualifying history yet.
|
||||||
|
-- Day-of-year distance is circular (#1256): plain ABS made Dec 28 vs
|
||||||
|
-- Jan 3 read as 359 days apart, silently gutting the window for ~3
|
||||||
|
-- weeks around every New Year — precisely when holiday nostalgia is
|
||||||
|
-- the point. LEAST(d, 365-d) wraps the boundary; leap-year drift of
|
||||||
|
-- ±1 is absorbed by the ±10 window. The build date ($2, already here
|
||||||
|
-- for the md5 rotation) anchors "today" instead of now() so the
|
||||||
|
-- window is testable and consistent with the mix's daily determinism.
|
||||||
-- $1 user_id, $2 date string.
|
-- $1 user_id, $2 date string.
|
||||||
WITH windowed AS (
|
WITH windowed AS (
|
||||||
SELECT track_id, COUNT(*) AS c
|
SELECT track_id, COUNT(*) AS c
|
||||||
FROM play_events
|
FROM play_events
|
||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
AND started_at < now() - interval '30 days'
|
AND started_at < now() - interval '30 days'
|
||||||
AND ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM now())) <= 10
|
AND LEAST(
|
||||||
|
ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date)),
|
||||||
|
365 - ABS(EXTRACT(DOY FROM started_at) - EXTRACT(DOY FROM $2::date))
|
||||||
|
) <= 10
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
@@ -135,35 +195,65 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
LIMIT 200;
|
LIMIT 200;
|
||||||
|
|
||||||
-- name: ListFirstListensTracks :many
|
-- name: ListFirstListensTracks :many
|
||||||
-- #423 First Listens: albums the user has never played any track of.
|
-- #423 First Listens: songs the user has never heard or even
|
||||||
-- Tiered: liked-artist albums first, then played-artist albums, then
|
-- attempted. "Attempted" is track-level with a >=30s listen threshold
|
||||||
-- the rest — album-coherent within each tier. Not diversity-capped
|
-- (#1268, operator decision 2026-07-03) — the old version disqualified
|
||||||
-- (whole-album discovery). $1 user_id.
|
-- a whole album on ANY play_event, so a 2-second accidental brush
|
||||||
WITH heard_albums AS (
|
-- banished it permanently.
|
||||||
SELECT DISTINCT t.album_id
|
--
|
||||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
-- tier 1 the exact desire: albums with ZERO attempted tracks
|
||||||
WHERE pe.user_id = $1
|
-- tier 2 step back: barely-attempted albums (<=25% of tracks
|
||||||
|
-- reached 30s) — brushed, never explored. The already-
|
||||||
|
-- attempted tracks themselves are excluded; they're not
|
||||||
|
-- first listens.
|
||||||
|
--
|
||||||
|
-- Within each tier, affinity-ordered as before: liked artist, then
|
||||||
|
-- attempted-played artist (>=30s — skip-only contact isn't trust),
|
||||||
|
-- then the rest; album-coherent. The tier lands on
|
||||||
|
-- playlist_tracks.pick_kind via the producer. $1 user_id.
|
||||||
|
WITH attempted AS (
|
||||||
|
SELECT DISTINCT pe.track_id
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1 AND pe.duration_played_ms >= 30000
|
||||||
),
|
),
|
||||||
played_artists AS (
|
album_attempts AS (
|
||||||
|
SELECT t.album_id,
|
||||||
|
COUNT(a.track_id) AS attempted_count,
|
||||||
|
COUNT(*) AS track_count
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN attempted a ON a.track_id = t.id
|
||||||
|
GROUP BY t.album_id
|
||||||
|
),
|
||||||
|
attempted_artists AS (
|
||||||
SELECT DISTINCT t.artist_id
|
SELECT DISTINCT t.artist_id
|
||||||
FROM play_events pe JOIN tracks t ON t.id = pe.track_id
|
FROM attempted a JOIN tracks t ON t.id = a.track_id
|
||||||
WHERE pe.user_id = $1
|
),
|
||||||
|
albums_tiered AS (
|
||||||
|
SELECT aa.album_id,
|
||||||
|
CASE
|
||||||
|
WHEN aa.attempted_count = 0 THEN 1
|
||||||
|
WHEN aa.attempted_count::float / GREATEST(aa.track_count, 1) <= 0.25 THEN 2
|
||||||
|
END AS tier
|
||||||
|
FROM album_attempts aa
|
||||||
)
|
)
|
||||||
SELECT t.id, t.album_id, t.artist_id
|
SELECT t.id, t.album_id, t.artist_id, alt.tier::int AS tier
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
WHERE NOT EXISTS (SELECT 1 FROM heard_albums h WHERE h.album_id = al.id)
|
JOIN albums_tiered alt ON alt.album_id = al.id
|
||||||
|
WHERE alt.tier IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM attempted a WHERE a.track_id = t.id)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
WHERE q.user_id = $1 AND q.track_id = t.id
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
)
|
)
|
||||||
ORDER BY
|
ORDER BY
|
||||||
|
alt.tier,
|
||||||
(CASE
|
(CASE
|
||||||
WHEN EXISTS (
|
WHEN EXISTS (
|
||||||
SELECT 1 FROM general_likes_artists gla
|
SELECT 1 FROM general_likes_artists gla
|
||||||
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
|
WHERE gla.user_id = $1 AND gla.artist_id = al.artist_id
|
||||||
) THEN 0
|
) THEN 0
|
||||||
WHEN al.artist_id IN (SELECT artist_id FROM played_artists) THEN 1
|
WHEN al.artist_id IN (SELECT artist_id FROM attempted_artists) THEN 1
|
||||||
ELSE 2
|
ELSE 2
|
||||||
END),
|
END),
|
||||||
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
al.id, t.disc_number NULLS FIRST, t.track_number NULLS FIRST
|
||||||
|
|||||||
@@ -47,14 +47,30 @@ UPDATE system_playlist_runs
|
|||||||
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
|
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
|
||||||
|
|
||||||
-- name: PickSeedArtists :many
|
-- name: PickSeedArtists :many
|
||||||
-- Top-5 most-engaged distinct artist candidates in the user's last 7
|
-- Top-12 most-engaged distinct artist candidates, tiered so the
|
||||||
-- days. The Go-side picker (pickSeedArtistsForDay) shuffles these
|
-- "Songs like X" mixes never silently vanish (#1255): the old hard
|
||||||
-- daily-deterministically and takes the first 3 so the set of
|
-- 7-day window emptied the seed pool after a quiet week, and the
|
||||||
-- "Songs like X" mixes rotates day-to-day.
|
-- daily atomic-replace build then deleted every existing mix until
|
||||||
-- Score = unskipped-play count + 5 if user has liked the artist.
|
-- the user played something again. Same fallback shape as
|
||||||
WITH plays AS (
|
-- PickTopPlayedTracksForUser (For You's seeds):
|
||||||
|
-- tier 0 engagement in the last 7 days
|
||||||
|
-- tier 1 (only if tier 0 empty) last 30 days
|
||||||
|
-- tier 2 (only if tiers 0+1 empty) all-time
|
||||||
|
-- tier 3 (only if 0-2 empty) liked artists with no play history
|
||||||
|
-- All returned rows share one tier; produceSeedMixes maps it onto the
|
||||||
|
-- rule-#131 pick-kind ladder and stamps the built tracks, so metrics
|
||||||
|
-- can compare mixes seeded from fresh vs stale engagement.
|
||||||
|
-- Score = unskipped-play count + 5 if user has liked the artist. The
|
||||||
|
-- Go-side picker (pickSeedArtistsForDay) shuffles the 12
|
||||||
|
-- daily-deterministically and takes songsLikeSeedCount (6) so the mix
|
||||||
|
-- set both rotates day-to-day and fills the dedicated Home row (#1491).
|
||||||
|
WITH liked AS (
|
||||||
|
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
||||||
|
),
|
||||||
|
recent7 AS (
|
||||||
SELECT t.artist_id,
|
SELECT t.artist_id,
|
||||||
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
|
0 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
@@ -62,15 +78,53 @@ WITH plays AS (
|
|||||||
AND t.artist_id IS NOT NULL
|
AND t.artist_id IS NOT NULL
|
||||||
GROUP BY t.artist_id
|
GROUP BY t.artist_id
|
||||||
),
|
),
|
||||||
liked AS (
|
recent30 AS (
|
||||||
SELECT artist_id FROM general_likes_artists WHERE user_id = $1
|
SELECT t.artist_id,
|
||||||
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
|
1 AS tier
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.started_at > now() - INTERVAL '30 days'
|
||||||
|
AND t.artist_id IS NOT NULL
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
alltime AS (
|
||||||
|
SELECT t.artist_id,
|
||||||
|
COUNT(*) FILTER (WHERE pe.was_skipped = false) AS play_count,
|
||||||
|
2 AS tier
|
||||||
|
FROM play_events pe
|
||||||
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND t.artist_id IS NOT NULL
|
||||||
|
GROUP BY t.artist_id
|
||||||
|
),
|
||||||
|
likedonly AS (
|
||||||
|
SELECT l.artist_id, 0::bigint AS play_count, 3 AS tier
|
||||||
|
FROM liked l
|
||||||
|
),
|
||||||
|
chosen AS (
|
||||||
|
SELECT artist_id, play_count, tier FROM recent7
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, play_count, tier FROM recent30
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, play_count, tier FROM alltime
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM recent30)
|
||||||
|
UNION ALL
|
||||||
|
SELECT artist_id, play_count, tier FROM likedonly
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM recent7)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM recent30)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM alltime)
|
||||||
)
|
)
|
||||||
SELECT p.artist_id,
|
SELECT c.artist_id,
|
||||||
(p.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score
|
(c.play_count + CASE WHEN l.artist_id IS NOT NULL THEN 5 ELSE 0 END)::bigint AS score,
|
||||||
FROM plays p
|
c.tier::int AS tier
|
||||||
LEFT JOIN liked l ON l.artist_id = p.artist_id
|
FROM chosen c
|
||||||
ORDER BY score DESC, p.artist_id
|
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
||||||
LIMIT 5;
|
ORDER BY score DESC, c.artist_id
|
||||||
|
LIMIT 12;
|
||||||
|
|
||||||
-- name: PickTopPlayedTracksForUser :many
|
-- name: PickTopPlayedTracksForUser :many
|
||||||
-- For-You candidate seeds, tiered so For-You never silently vanishes:
|
-- For-You candidate seeds, tiered so For-You never silently vanishes:
|
||||||
@@ -78,10 +132,10 @@ SELECT p.artist_id,
|
|||||||
-- tier 1 (only if tier 0 empty) all-time top non-skip plays
|
-- tier 1 (only if tier 0 empty) all-time top non-skip plays
|
||||||
-- tier 2 (only if tiers 0+1 empty) liked tracks
|
-- tier 2 (only if tiers 0+1 empty) liked tracks
|
||||||
-- Returns up to 5 ids; tie-break by track_id for determinism. The
|
-- Returns up to 5 ids; tie-break by track_id for determinism. The
|
||||||
-- Go-side picker (pickForYouSeedForDay) rotates one per day via
|
-- Go-side picker (pickDailySeeds) draws the day's seeds from these.
|
||||||
-- userIDHash. Widened from a hard 7-day window, which made For-You
|
-- Widened from a hard 7-day window, which made For-You disappear
|
||||||
-- disappear after a week of not listening and never recover on a
|
-- after a week of not listening and never recover on a self-hosted
|
||||||
-- self-hosted library with sparse history.
|
-- library with sparse history.
|
||||||
WITH recent AS (
|
WITH recent AS (
|
||||||
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
SELECT t.id, COUNT(*) AS c, 0 AS tier
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
-- Tag-enrichment provider settings queries (milestone #160, #1490 Step 2).
|
||||||
|
-- The tags.SettingsService consumes these — parallel to the cover-art
|
||||||
|
-- SettingsService (coverart_settings.sql). Keeping the two independent
|
||||||
|
-- means a new tag source is added without touching the art settings.
|
||||||
|
|
||||||
|
-- name: ListTagProviderSettings :many
|
||||||
|
-- All rows, for boot reconciliation and the admin GET handler.
|
||||||
|
SELECT provider_id, enabled, api_key, display_order, created_at, updated_at
|
||||||
|
FROM tag_provider_settings
|
||||||
|
ORDER BY display_order, provider_id;
|
||||||
|
|
||||||
|
-- name: UpsertTagProviderSettings :exec
|
||||||
|
-- INSERT a default row for a newly-registered provider, or refresh an
|
||||||
|
-- existing row's display_order on boot. Does NOT overwrite enabled /
|
||||||
|
-- api_key on conflict — the operator's settings persist across restarts.
|
||||||
|
INSERT INTO tag_provider_settings (provider_id, enabled, display_order)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (provider_id) DO UPDATE
|
||||||
|
SET display_order = EXCLUDED.display_order,
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
-- name: UpdateTagProviderSettings :exec
|
||||||
|
-- Admin PATCH. $2 sets enabled. Pass FALSE for the $3 apiKeyChanged flag
|
||||||
|
-- to leave api_key untouched; TRUE with empty string clears it, TRUE with
|
||||||
|
-- a value sets it.
|
||||||
|
UPDATE tag_provider_settings
|
||||||
|
SET enabled = COALESCE($2, enabled),
|
||||||
|
api_key = CASE WHEN $3::boolean THEN $4 ELSE api_key END,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE provider_id = $1;
|
||||||
|
|
||||||
|
-- name: GetCurrentTagSourcesVersion :one
|
||||||
|
SELECT current_version FROM tag_sources_meta WHERE id = true;
|
||||||
|
|
||||||
|
-- name: BumpTagSourcesVersion :one
|
||||||
|
-- Increment + return the new version. Called only when the enabled set
|
||||||
|
-- changes (not on key edits).
|
||||||
|
UPDATE tag_sources_meta
|
||||||
|
SET current_version = current_version + 1
|
||||||
|
WHERE id = true
|
||||||
|
RETURNING current_version;
|
||||||
|
|
||||||
|
-- name: GetTagProvidersHash :one
|
||||||
|
SELECT last_registered_providers_hash
|
||||||
|
FROM tag_sources_meta
|
||||||
|
WHERE id = true;
|
||||||
|
|
||||||
|
-- name: BumpTagVersionAndSetProvidersHash :one
|
||||||
|
-- Atomically increment the version and store the new registered-provider
|
||||||
|
-- hash. Called at boot when the compiled-in provider set changed, so
|
||||||
|
-- 'none' rows become eligible for a retry through the new chain.
|
||||||
|
UPDATE tag_sources_meta
|
||||||
|
SET current_version = current_version + 1,
|
||||||
|
last_registered_providers_hash = $1
|
||||||
|
WHERE id = true
|
||||||
|
RETURNING current_version;
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||||
-- is split into tags in Go. Quarantined tracks are excluded.
|
-- is split into tags in Go. Quarantined tracks are excluded.
|
||||||
SELECT
|
SELECT
|
||||||
|
t.id AS track_id,
|
||||||
t.artist_id,
|
t.artist_id,
|
||||||
t.genre,
|
t.genre,
|
||||||
LEAST(GREATEST(
|
LEAST(GREATEST(
|
||||||
@@ -25,9 +26,10 @@ WHERE pe.user_id = $1
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- name: ListLikedTrackTasteInputsForUser :many
|
-- name: ListLikedTrackTasteInputsForUser :many
|
||||||
-- (artist_id, genre) for each track the user has explicitly liked. Feeds the
|
-- (track_id, artist_id, genre) for each track the user has explicitly
|
||||||
-- track-like bonus into the liked track's artist and tags.
|
-- liked. Feeds the track-like bonus into the liked track's artist and
|
||||||
SELECT t.artist_id, t.genre
|
-- tags; track_id keys the enriched track_tags lookup (#1490).
|
||||||
|
SELECT t.id AS track_id, t.artist_id, t.genre
|
||||||
FROM general_likes gl
|
FROM general_likes gl
|
||||||
JOIN tracks t ON t.id = gl.track_id
|
JOIN tracks t ON t.id = gl.track_id
|
||||||
WHERE gl.user_id = $1;
|
WHERE gl.user_id = $1;
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- Track-tag enrichment queries (milestone #160, #1490). The tag enricher
|
||||||
|
-- drains ListTracksMissingTags, fetches from the provider chain, and
|
||||||
|
-- writes the merged top-K via DeleteTrackTags + InsertTrackTag, then
|
||||||
|
-- stamps SetTrackTagSource. Mirrors the coverart artist-enricher shape.
|
||||||
|
|
||||||
|
-- name: ListTracksMissingTags :many
|
||||||
|
-- Tracks eligible for tag enrichment: never processed (tag_source NULL)
|
||||||
|
-- or previously settled 'none' under an older provider version. Returns
|
||||||
|
-- the fields the provider chain needs — recording MBID (nullable) for
|
||||||
|
-- keyed lookups, plus title + artist name for name-based fallback.
|
||||||
|
-- $1 = current tag_sources_version, $2 = limit.
|
||||||
|
SELECT t.id, t.mbid, t.title, a.name AS artist_name
|
||||||
|
FROM tracks t
|
||||||
|
JOIN artists a ON a.id = t.artist_id
|
||||||
|
WHERE t.tag_source IS NULL
|
||||||
|
OR (t.tag_source = 'none' AND t.tag_sources_version < $1)
|
||||||
|
ORDER BY t.id
|
||||||
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- name: DeleteTrackTags :exec
|
||||||
|
-- Clear a track's cached tags before rewriting (atomic replace by the caller).
|
||||||
|
DELETE FROM track_tags WHERE track_id = $1;
|
||||||
|
|
||||||
|
-- name: InsertTrackTag :exec
|
||||||
|
-- Upsert one (track, tag); keep the stronger weight when two providers
|
||||||
|
-- agree on a tag with different folksonomy strengths.
|
||||||
|
INSERT INTO track_tags (track_id, tag, weight)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (track_id, tag)
|
||||||
|
DO UPDATE SET weight = GREATEST(track_tags.weight, EXCLUDED.weight);
|
||||||
|
|
||||||
|
-- name: SetTrackTagSource :exec
|
||||||
|
-- Stamp the enrichment outcome so the batch drainer skips settled rows.
|
||||||
|
-- $2 = 'lastfm' | 'musicbrainz' | 'mixed' | 'none', $3 = current version.
|
||||||
|
UPDATE tracks SET tag_source = $2, tag_sources_version = $3 WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ListPlayedTrackTagsForUser :many
|
||||||
|
-- The enriched tags for every track this user played inside the taste
|
||||||
|
-- window, so the recompute can union them into the tag facet alongside
|
||||||
|
-- ID3 genre. $1 = user_id, $2 = window_days. One row per (track, tag).
|
||||||
|
SELECT tt.track_id, tt.tag, tt.weight
|
||||||
|
FROM track_tags tt
|
||||||
|
WHERE tt.track_id IN (
|
||||||
|
SELECT DISTINCT pe.track_id
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.was_skipped = false
|
||||||
|
AND pe.started_at > now() - make_interval(days => $2::int)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- name: ListLikedTrackTagsForUser :many
|
||||||
|
-- Enriched tags for the user's liked tracks — the like-bonus path in the
|
||||||
|
-- recompute mirrors the play path, so it needs the same tag lookup.
|
||||||
|
SELECT tt.track_id, tt.tag, tt.weight
|
||||||
|
FROM track_tags tt
|
||||||
|
JOIN general_likes gl ON gl.track_id = tt.track_id
|
||||||
|
WHERE gl.user_id = $1;
|
||||||
@@ -62,6 +62,11 @@ var dataTables = []string{
|
|||||||
// (never recreates the singleton, seeded once by 0018); ResetDB
|
// (never recreates the singleton, seeded once by 0018); ResetDB
|
||||||
// resets its counter via UPDATE below instead.
|
// resets its counter via UPDATE below instead.
|
||||||
"cover_art_provider_settings",
|
"cover_art_provider_settings",
|
||||||
|
// recsettings.New reconciles shipped defaults on every construction
|
||||||
|
// (#1250), so truncating gives each test pristine tuning values.
|
||||||
|
"recommendation_weight_profiles",
|
||||||
|
"taste_tuning",
|
||||||
|
"recommendation_tuning_audit",
|
||||||
"tracks",
|
"tracks",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
|
|||||||
@@ -97,6 +97,36 @@ var systemPlaylistSources = map[string]bool{
|
|||||||
"songs_like_artist": true,
|
"songs_like_artist": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lookupSystemPickKind resolves a system-playlist play's pick_kind
|
||||||
|
// (taste/fresh, Discover's bucket, or a rule-#131 tier) from the user's
|
||||||
|
// live snapshot of that variant at ingestion time. Attribution must be
|
||||||
|
// frozen now — snapshots rebuild daily, so it can't be reconstructed at
|
||||||
|
// metrics-read time (#1249, generalized in #1270). Source strings
|
||||||
|
// double as system_variant values (see systemPlaylistSources). Returns
|
||||||
|
// nil (unattributed) when the track isn't in the current snapshot —
|
||||||
|
// plays predating the feature, or an offline replay arriving after the
|
||||||
|
// track rotated out — and when the variant doesn't stamp yet. Real DB
|
||||||
|
// errors propagate and fail the caller's transaction.
|
||||||
|
func lookupSystemPickKind(
|
||||||
|
ctx context.Context,
|
||||||
|
q *dbq.Queries,
|
||||||
|
userID, trackID pgtype.UUID,
|
||||||
|
variant string,
|
||||||
|
) (*string, error) {
|
||||||
|
kind, err := q.GetSystemPickKindForTrack(ctx, dbq.GetSystemPickKindForTrackParams{
|
||||||
|
UserID: userID,
|
||||||
|
SystemVariant: &variant,
|
||||||
|
TrackID: trackID,
|
||||||
|
})
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return kind, nil
|
||||||
|
}
|
||||||
|
|
||||||
// RecordPlayStartedWithSource is RecordPlayStarted plus a `source`
|
// RecordPlayStartedWithSource is RecordPlayStarted plus a `source`
|
||||||
// tag identifying which surface the play came from. When source is a
|
// tag identifying which surface the play came from. When source is a
|
||||||
// known system-playlist kind the track is appended to that user's
|
// known system-playlist kind the track is appended to that user's
|
||||||
@@ -127,6 +157,13 @@ func (w *Writer) RecordPlayStartedWithSource(
|
|||||||
if source != "" {
|
if source != "" {
|
||||||
sourcePtr = &source
|
sourcePtr = &source
|
||||||
}
|
}
|
||||||
|
var pickKind *string
|
||||||
|
if systemPlaylistSources[source] {
|
||||||
|
pickKind, err = lookupSystemPickKind(ctx, q, userID, trackID, source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
TrackID: trackID,
|
TrackID: trackID,
|
||||||
@@ -134,6 +171,7 @@ func (w *Writer) RecordPlayStartedWithSource(
|
|||||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||||
ClientID: clientIDPtr,
|
ClientID: clientIDPtr,
|
||||||
Source: sourcePtr,
|
Source: sourcePtr,
|
||||||
|
PickKind: pickKind,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -395,6 +433,13 @@ func (w *Writer) RecordOfflinePlay(
|
|||||||
if source != "" {
|
if source != "" {
|
||||||
sourcePtr = &source
|
sourcePtr = &source
|
||||||
}
|
}
|
||||||
|
var pickKind *string
|
||||||
|
if systemPlaylistSources[source] {
|
||||||
|
pickKind, err = lookupSystemPickKind(ctx, q, userID, trackID, source)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
TrackID: trackID,
|
TrackID: trackID,
|
||||||
@@ -402,6 +447,7 @@ func (w *Writer) RecordOfflinePlay(
|
|||||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||||
ClientID: clientIDPtr,
|
ClientID: clientIDPtr,
|
||||||
Source: sourcePtr,
|
Source: sourcePtr,
|
||||||
|
PickKind: pickKind,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -488,3 +488,123 @@ func TestRecordPlayStarted_NoRotationWithoutSource(t *testing.T) {
|
|||||||
t.Errorf("expected no rotation row for a source-less play")
|
t.Errorf("expected no rotation row for a source-less play")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedSystemSnapshot creates a system playlist of the given variant
|
||||||
|
// containing the fixture track with the given pick_kind, mimicking
|
||||||
|
// what the builder persists (#1249, generalized #1270).
|
||||||
|
func seedSystemSnapshot(t *testing.T, f fixture, variant, pickKind string) {
|
||||||
|
t.Helper()
|
||||||
|
pl, err := f.q.CreateSystemPlaylist(context.Background(), dbq.CreateSystemPlaylistParams{
|
||||||
|
UserID: f.user, Name: variant, SystemVariant: &variant,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSystemPlaylist: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := f.q.AppendPlaylistTrack(context.Background(), dbq.AppendPlaylistTrackParams{
|
||||||
|
PlaylistID: pl.ID, TrackID: f.track, PickKind: &pickKind,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("AppendPlaylistTrack: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
|
||||||
|
f := newFixture(t, 200_000)
|
||||||
|
seedSystemSnapshot(t, f, "for_you", "fresh")
|
||||||
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
|
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
|
}
|
||||||
|
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.PickKind == nil || *got.PickKind != "fresh" {
|
||||||
|
t.Errorf("pick_kind = %v, want fresh (frozen from live snapshot)", got.PickKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordPlayStartedWithSource_NoSnapshotMatch_PickKindNull(t *testing.T) {
|
||||||
|
// A for_you play whose track isn't in the current snapshot (rotated
|
||||||
|
// out, or no snapshot exists) stays unattributed rather than guessing.
|
||||||
|
f := newFixture(t, 200_000)
|
||||||
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
|
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
|
}
|
||||||
|
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.PickKind != nil {
|
||||||
|
t.Errorf("pick_kind = %q, want NULL (track not in snapshot)", *got.PickKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
|
||||||
|
// Attribution is variant-scoped: even with a For You snapshot
|
||||||
|
// containing the track, a play from a different surface must not
|
||||||
|
// pick up For You's stamp — it reads its OWN variant's snapshot,
|
||||||
|
// which here doesn't exist.
|
||||||
|
f := newFixture(t, 200_000)
|
||||||
|
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||||
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
|
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
|
}
|
||||||
|
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.PickKind != nil {
|
||||||
|
t.Errorf("pick_kind = %q, want NULL for a cross-variant play", *got.PickKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
|
||||||
|
// Discover stamps its candidate bucket (#1270); a discover play whose
|
||||||
|
// track is in the live snapshot freezes that bucket onto the play.
|
||||||
|
f := newFixture(t, 200_000)
|
||||||
|
seedSystemSnapshot(t, f, "discover", "dormant")
|
||||||
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
|
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
|
}
|
||||||
|
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.PickKind == nil || *got.PickKind != "dormant" {
|
||||||
|
t.Errorf("pick_kind = %v, want dormant (frozen from live snapshot)", got.PickKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
|
||||||
|
// The offline replay path threads source the same way; a replayed
|
||||||
|
// for_you play whose track is still in the snapshot gets attributed.
|
||||||
|
f := newFixture(t, 200_000)
|
||||||
|
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||||
|
at := time.Now().UTC().Add(-time.Hour)
|
||||||
|
if err := f.w.RecordOfflinePlay(
|
||||||
|
context.Background(), f.user, f.track, "c", "for_you", at, 180_000,
|
||||||
|
); err != nil {
|
||||||
|
t.Fatalf("RecordOfflinePlay: %v", err)
|
||||||
|
}
|
||||||
|
var pickKind *string
|
||||||
|
if err := f.pool.QueryRow(context.Background(),
|
||||||
|
`SELECT pick_kind FROM play_events WHERE user_id = $1 AND track_id = $2`,
|
||||||
|
f.user, f.track,
|
||||||
|
).Scan(&pickKind); err != nil {
|
||||||
|
t.Fatalf("select: %v", err)
|
||||||
|
}
|
||||||
|
if pickKind == nil || *pickKind != "taste" {
|
||||||
|
t.Errorf("pick_kind = %v, want taste", pickKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,26 +10,39 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
discoverTotalSlots = 100
|
discoverTotalSlots = 100
|
||||||
discoverDormantSlots = 40
|
// Bucket allocation, pushed toward *targeted* novelty (Scribe #1488):
|
||||||
discoverCrossUserSlots = 30
|
// the taste-matched-unheard arm gets the plurality, dormant stays a
|
||||||
discoverRandomSlots = 30
|
// familiar anchor, and the crude random arm is trimmed to a
|
||||||
|
// serendipity sliver. On a single-user server cross_user is empty and
|
||||||
|
// its slots redistribute, so taste_unheard becomes the dominant real
|
||||||
|
// arm — Discover actually discovers instead of re-warming known artists.
|
||||||
|
discoverTasteUnheardSlots = 35
|
||||||
|
discoverDormantSlots = 30
|
||||||
|
discoverCrossUserSlots = 20
|
||||||
|
discoverRandomSlots = 15
|
||||||
discoverMaxTracksPerAlbum = 2
|
discoverMaxTracksPerAlbum = 2
|
||||||
discoverMaxTracksPerArtist = 3
|
discoverMaxTracksPerArtist = 3
|
||||||
)
|
)
|
||||||
|
|
||||||
// discoverTrack is the common shape used by the bucket allocator. The
|
// discoverTrack is the common shape used by the bucket allocator. The
|
||||||
// three sqlc-generated row types collapse into this internal struct so
|
// three sqlc-generated row types collapse into this internal struct so
|
||||||
// downstream functions don't need to be generic over them.
|
// downstream functions don't need to be generic over them. PickKind is
|
||||||
|
// the originating bucket (taste_unheard/dormant/cross_user/random,
|
||||||
|
// #1270/#1488), stamped in the row adapters so it survives the
|
||||||
|
// interleave — bucket identity is what makes the slot allocation
|
||||||
|
// measurable in the metrics.
|
||||||
type discoverTrack struct {
|
type discoverTrack struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
|
PickKind string
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildDiscoverCandidates assembles the Discover playlist track list.
|
// buildDiscoverCandidates assembles the Discover playlist track list.
|
||||||
// Pulls from three buckets, applies per-album/per-artist caps, then
|
// Pulls from four buckets (taste_unheard / dormant / cross_user /
|
||||||
// redistributes any deficit equally across the remaining buckets.
|
// random), applies per-album/per-artist caps, then redistributes any
|
||||||
|
// deficit equally across the remaining buckets.
|
||||||
//
|
//
|
||||||
// Returns up to discoverTotalSlots track IDs in the order they should
|
// Returns up to discoverTotalSlots track IDs in the order they should
|
||||||
// appear in the playlist (round-robin interleaved across buckets).
|
// appear in the playlist (round-robin interleaved across buckets).
|
||||||
@@ -39,6 +52,57 @@ type discoverTrack struct {
|
|||||||
// the redistribution algorithm rolls the deficit into the surviving
|
// the redistribution algorithm rolls the deficit into the surviving
|
||||||
// buckets so one broken bucket can't silently kill the whole playlist.
|
// buckets so one broken bucket can't silently kill the whole playlist.
|
||||||
func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) ([]rankedCandidate, error) {
|
func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) ([]rankedCandidate, error) {
|
||||||
|
pools := loadDiscoverPools(ctx, q, logger, userID, dateStr)
|
||||||
|
|
||||||
|
// Allocate slots with redistribution. Order fixes both the allocation
|
||||||
|
// index mapping and (below) the interleave/dedup priority.
|
||||||
|
allocations := redistributeSlots([]bucketRequest{
|
||||||
|
{want: discoverTasteUnheardSlots, available: len(pools.taste)},
|
||||||
|
{want: discoverDormantSlots, available: len(pools.dormant)},
|
||||||
|
{want: discoverCrossUserSlots, available: len(pools.crossUser)},
|
||||||
|
{want: discoverRandomSlots, available: len(pools.random)},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Round-robin interleave so the playlist doesn't front-load one
|
||||||
|
// flavour. taste_unheard leads so a track shared with another bucket
|
||||||
|
// keeps the taste stamp — the targeted-novelty arm stays measurable.
|
||||||
|
out := interleaveBuckets(
|
||||||
|
pools.taste[:allocations[0]],
|
||||||
|
pools.dormant[:allocations[1]],
|
||||||
|
pools.crossUser[:allocations[2]],
|
||||||
|
pools.random[:allocations[3]],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert to rankedCandidate (the type insertSystemPlaylist accepts).
|
||||||
|
// Score is unused for Discover — the daily-deterministic md5 ordering
|
||||||
|
// already gave us the ranking.
|
||||||
|
ranked := make([]rankedCandidate, 0, len(out))
|
||||||
|
for _, t := range out {
|
||||||
|
ranked = append(ranked, rankedCandidate{TrackID: t.ID, PickKind: t.PickKind})
|
||||||
|
}
|
||||||
|
return ranked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverPools holds the four Discover candidate buckets after adapting
|
||||||
|
// + per-album/per-artist capping. Bucket query failures degrade to an
|
||||||
|
// empty pool (logged) so one broken bucket can't kill the whole playlist;
|
||||||
|
// the slot redistribution rolls its deficit into the survivors.
|
||||||
|
type discoverPools struct {
|
||||||
|
taste []discoverTrack
|
||||||
|
dormant []discoverTrack
|
||||||
|
crossUser []discoverTrack
|
||||||
|
random []discoverTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDiscoverPools(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) discoverPools {
|
||||||
|
tasteRows, err := q.ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
|
||||||
|
UserID: userID, Column2: dateStr,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("discover: taste-unheard bucket failed; continuing with empty pool",
|
||||||
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
|
tasteRows = nil
|
||||||
|
}
|
||||||
dormantRows, err := q.ListDormantArtistTracksForDiscover(ctx, dbq.ListDormantArtistTracksForDiscoverParams{
|
dormantRows, err := q.ListDormantArtistTracksForDiscover(ctx, dbq.ListDormantArtistTracksForDiscoverParams{
|
||||||
UserID: userID, Column2: dateStr,
|
UserID: userID, Column2: dateStr,
|
||||||
})
|
})
|
||||||
@@ -63,43 +127,32 @@ func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.L
|
|||||||
"user_id", uuidStringPL(userID), "err", err)
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
randomRows = nil
|
randomRows = nil
|
||||||
}
|
}
|
||||||
|
return discoverPools{
|
||||||
// Adapt sqlc-generated rows into the internal struct, then apply
|
taste: capByAlbumAndArtist(tasteUnheardRowsToTracks(tasteRows)),
|
||||||
// per-album / per-artist caps.
|
dormant: capByAlbumAndArtist(dormantRowsToTracks(dormantRows)),
|
||||||
dormantPool := capByAlbumAndArtist(dormantRowsToTracks(dormantRows))
|
crossUser: capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows)),
|
||||||
crossUserPool := capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows))
|
random: capByAlbumAndArtist(randomRowsToTracks(randomRows)),
|
||||||
randomPool := capByAlbumAndArtist(randomRowsToTracks(randomRows))
|
|
||||||
|
|
||||||
// Allocate slots with redistribution.
|
|
||||||
allocations := redistributeSlots([]bucketRequest{
|
|
||||||
{want: discoverDormantSlots, available: len(dormantPool)},
|
|
||||||
{want: discoverCrossUserSlots, available: len(crossUserPool)},
|
|
||||||
{want: discoverRandomSlots, available: len(randomPool)},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Take the head of each bucket's capped pool.
|
|
||||||
dormantTake := dormantPool[:allocations[0]]
|
|
||||||
crossUserTake := crossUserPool[:allocations[1]]
|
|
||||||
randomTake := randomPool[:allocations[2]]
|
|
||||||
|
|
||||||
// Round-robin interleave so the playlist doesn't front-load one
|
|
||||||
// flavour.
|
|
||||||
out := interleaveBuckets(dormantTake, crossUserTake, randomTake)
|
|
||||||
|
|
||||||
// Convert to rankedCandidate (the type insertSystemPlaylist accepts).
|
|
||||||
// Score is unused for Discover — the daily-deterministic md5 ordering
|
|
||||||
// already gave us the ranking.
|
|
||||||
ranked := make([]rankedCandidate, 0, len(out))
|
|
||||||
for _, t := range out {
|
|
||||||
ranked = append(ranked, rankedCandidate{TrackID: t.ID})
|
|
||||||
}
|
}
|
||||||
return ranked, nil
|
}
|
||||||
|
|
||||||
|
func tasteUnheardRowsToTracks(rows []dbq.ListTasteUnheardTracksForDiscoverRow) []discoverTrack {
|
||||||
|
out := make([]discoverTrack, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = discoverTrack{
|
||||||
|
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
||||||
|
PickKind: pickKindTasteUnheard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
|
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
|
||||||
out := make([]discoverTrack, len(rows))
|
out := make([]discoverTrack, len(rows))
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
out[i] = discoverTrack{
|
||||||
|
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
||||||
|
PickKind: pickKindDormant,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -107,7 +160,10 @@ func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []dis
|
|||||||
func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack {
|
func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack {
|
||||||
out := make([]discoverTrack, len(rows))
|
out := make([]discoverTrack, len(rows))
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
out[i] = discoverTrack{
|
||||||
|
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
||||||
|
PickKind: pickKindCrossUser,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -115,7 +171,10 @@ func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []
|
|||||||
func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack {
|
func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack {
|
||||||
out := make([]discoverTrack, len(rows))
|
out := make([]discoverTrack, len(rows))
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
out[i] = discoverTrack{
|
||||||
|
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
||||||
|
PickKind: pickKindRandom,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,3 +161,27 @@ func TestInterleaveBuckets_DedupsAcrossBuckets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInterleaveBuckets_PreservesBucketPickKind(t *testing.T) {
|
||||||
|
// Bucket provenance (#1270) is stamped on discoverTrack before the
|
||||||
|
// interleave and must survive it — including dedup, where a track in
|
||||||
|
// two buckets keeps the stamp of the bucket it was taken from.
|
||||||
|
dormant := []discoverTrack{
|
||||||
|
{ID: uuidN(1), PickKind: pickKindDormant},
|
||||||
|
{ID: uuidN(2), PickKind: pickKindDormant},
|
||||||
|
}
|
||||||
|
random := []discoverTrack{
|
||||||
|
{ID: uuidN(1), PickKind: pickKindRandom}, // shared with dormant
|
||||||
|
{ID: uuidN(3), PickKind: pickKindRandom},
|
||||||
|
}
|
||||||
|
got := interleaveBuckets(dormant, nil, random)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("len = %d, want 3", len(got))
|
||||||
|
}
|
||||||
|
want := map[byte]string{1: pickKindDormant, 2: pickKindDormant, 3: pickKindRandom}
|
||||||
|
for _, tr := range got {
|
||||||
|
if w := want[tr.ID.Bytes[15]]; tr.PickKind != w {
|
||||||
|
t.Errorf("track %d pick_kind = %q, want %q", tr.ID.Bytes[15], tr.PickKind, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ func TestPickHeadAndTail_SmallPool(t *testing.T) {
|
|||||||
makeCand(2, 11, 101, 0.9),
|
makeCand(2, 11, 101, 0.9),
|
||||||
makeCand(3, 12, 102, 0.8),
|
makeCand(3, 12, 102, 0.8),
|
||||||
}
|
}
|
||||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||||
if len(got) != 3 {
|
if len(got) != 3 {
|
||||||
t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got))
|
t.Errorf("len = %d, want 3 (pool too small for head/tail split)", len(got))
|
||||||
}
|
}
|
||||||
@@ -112,7 +112,7 @@ func TestPickHeadAndTail_ExactlyTotal(t *testing.T) {
|
|||||||
for i := 0; i < 7; i++ {
|
for i := 0; i < 7; i++ {
|
||||||
in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)))
|
in = append(in, makeCand(i+1, i+1, i+1, float64(7-i)))
|
||||||
}
|
}
|
||||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||||
if len(got) != 7 {
|
if len(got) != 7 {
|
||||||
t.Errorf("len = %d, want 7 (pool == total)", len(got))
|
t.Errorf("len = %d, want 7 (pool == total)", len(got))
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ func TestPickHeadAndTail_HeadAndTailSplit(t *testing.T) {
|
|||||||
for i := 0; i < 100; i++ {
|
for i := 0; i < 100; i++ {
|
||||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||||
}
|
}
|
||||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5)
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
|
||||||
if len(got) != 25 {
|
if len(got) != 25 {
|
||||||
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
|
t.Errorf("len = %d, want 25 (20 head + 5 tail)", len(got))
|
||||||
}
|
}
|
||||||
@@ -139,8 +139,8 @@ func TestPickHeadAndTail_Determinism(t *testing.T) {
|
|||||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
got1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
got1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
||||||
got2 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
got2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
||||||
if len(got1) != len(got2) {
|
if len(got1) != len(got2) {
|
||||||
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
|
t.Fatalf("len mismatch: %d vs %d", len(got1), len(got2))
|
||||||
}
|
}
|
||||||
@@ -161,8 +161,8 @@ func TestPickHeadAndTail_HeadStable_TailVariesAcrossDays(t *testing.T) {
|
|||||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
day1 := pickHeadAndTail(in, testUserID, "2026-05-07", now, 20, 5)
|
day1 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", now, 20, 5)
|
||||||
day2 := pickHeadAndTail(in, testUserID, "2026-05-08", now, 20, 5)
|
day2 := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-08", now, 20, 5)
|
||||||
if len(day1) != 25 || len(day2) != 25 {
|
if len(day1) != 25 || len(day2) != 25 {
|
||||||
t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2))
|
t.Fatalf("len mismatch: day1=%d day2=%d", len(day1), len(day2))
|
||||||
}
|
}
|
||||||
@@ -205,7 +205,7 @@ func TestPickHeadAndTail_TailFromBeyond2xHeadN(t *testing.T) {
|
|||||||
sim := float64(50 - i)
|
sim := float64(50 - i)
|
||||||
in = append(in, makeCand(i+1, i+1, i+1, sim))
|
in = append(in, makeCand(i+1, i+1, i+1, sim))
|
||||||
}
|
}
|
||||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 3)
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 3)
|
||||||
if len(got) != 8 {
|
if len(got) != 8 {
|
||||||
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
|
t.Fatalf("len = %d, want 8 (5 head + 3 tail)", len(got))
|
||||||
}
|
}
|
||||||
@@ -243,3 +243,190 @@ func TestPickTopN_DiversityCap(t *testing.T) {
|
|||||||
t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got))
|
t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPickHeadAndTail_MarksPickKinds(t *testing.T) {
|
||||||
|
// 100-deep pool with headN=20, tailN=5: the 20 head entries are the
|
||||||
|
// taste picks, the 5 tail entries the freshness injection (#1249).
|
||||||
|
in := make([]recommendation.Candidate, 0, 100)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||||
|
}
|
||||||
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 20, 5)
|
||||||
|
if len(got) != 25 {
|
||||||
|
t.Fatalf("len = %d, want 25", len(got))
|
||||||
|
}
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if got[i].PickKind != pickKindTaste {
|
||||||
|
t.Errorf("head[%d].PickKind = %q, want %q", i, got[i].PickKind, pickKindTaste)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 20; i < 25; i++ {
|
||||||
|
if got[i].PickKind != pickKindFresh {
|
||||||
|
t.Errorf("tail[%d].PickKind = %q, want %q", i-20, got[i].PickKind, pickKindFresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickHeadAndTail_SmallPoolAllTaste(t *testing.T) {
|
||||||
|
// The small-pool fallback is pure top-N-by-score — that IS the taste
|
||||||
|
// mechanism, so nothing on this path is an exploration pick.
|
||||||
|
in := []recommendation.Candidate{
|
||||||
|
makeCand(1, 10, 100, 1.0),
|
||||||
|
makeCand(2, 11, 101, 0.9),
|
||||||
|
makeCand(3, 12, 102, 0.8),
|
||||||
|
}
|
||||||
|
got := pickHeadAndTail(in, nil, 1, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||||
|
for i, rc := range got {
|
||||||
|
if rc.PickKind != pickKindTaste {
|
||||||
|
t.Errorf("got[%d].PickKind = %q, want %q (fallback is all taste)",
|
||||||
|
i, rc.PickKind, pickKindTaste)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickTopN_NoPickKind(t *testing.T) {
|
||||||
|
// Songs-like-X and the discovery mixes don't split; their rows must
|
||||||
|
// persist pick_kind NULL (empty string here).
|
||||||
|
in := []recommendation.Candidate{makeCand(1, 10, 100, 1.0)}
|
||||||
|
got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25)
|
||||||
|
if len(got) != 1 || got[0].PickKind != "" {
|
||||||
|
t.Errorf("pickTopN PickKind = %q, want empty (persists as NULL)", got[0].PickKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHeadQuotas(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
seeds, headN int
|
||||||
|
want []int
|
||||||
|
}{
|
||||||
|
{1, 50, []int{50}},
|
||||||
|
{2, 50, []int{30, 20}},
|
||||||
|
{3, 50, []int{25, 15, 10}},
|
||||||
|
{3, 7, []int{4, 2, 1}}, // rounding remainder → primary seed
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := headQuotas(c.seeds, c.headN)
|
||||||
|
if len(got) != len(c.want) {
|
||||||
|
t.Fatalf("headQuotas(%d, %d) len = %d, want %d", c.seeds, c.headN, len(got), len(c.want))
|
||||||
|
}
|
||||||
|
sum := 0
|
||||||
|
for i := range got {
|
||||||
|
sum += got[i]
|
||||||
|
if got[i] != c.want[i] {
|
||||||
|
t.Errorf("headQuotas(%d, %d)[%d] = %d, want %d", c.seeds, c.headN, i, got[i], c.want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sum != c.headN {
|
||||||
|
t.Errorf("headQuotas(%d, %d) sums to %d, want %d", c.seeds, c.headN, sum, c.headN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickQuotaHead_BlendsSeeds(t *testing.T) {
|
||||||
|
// Seed 0's neighborhood out-scores everything (#1269): without
|
||||||
|
// quotas the head would be all seed-0. With headN=10 and quotas
|
||||||
|
// [5,3,2], the walk takes the best 5 from seed 0, then seeds 1 and
|
||||||
|
// 2 get their guaranteed slots from deeper ranks.
|
||||||
|
in := make([]recommendation.Candidate, 0, 30)
|
||||||
|
seedOf := map[pgtype.UUID]int{}
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
c := makeCand(i+1, i+1, i+1, float64(30-i))
|
||||||
|
si := 0
|
||||||
|
if i >= 20 && i < 25 {
|
||||||
|
si = 1
|
||||||
|
} else if i >= 25 {
|
||||||
|
si = 2
|
||||||
|
}
|
||||||
|
seedOf[c.Track.ID] = si
|
||||||
|
in = append(in, c)
|
||||||
|
}
|
||||||
|
// Input is already score-descending and cap-free.
|
||||||
|
head := pickQuotaHead(in, seedOf, 3, 10)
|
||||||
|
if len(head) != 10 {
|
||||||
|
t.Fatalf("head len = %d, want 10", len(head))
|
||||||
|
}
|
||||||
|
want := []byte{1, 2, 3, 4, 5, 21, 22, 23, 26, 27}
|
||||||
|
for i, w := range want {
|
||||||
|
if head[i].Track.ID.Bytes[15] != w {
|
||||||
|
t.Errorf("head[%d] = track %d, want %d", i, head[i].Track.ID.Bytes[15], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickQuotaHead_ThinSeedSpillsBestFirst(t *testing.T) {
|
||||||
|
// Seed 1 has a single candidate; its unfilled quota goes to the
|
||||||
|
// best remaining candidates regardless of seed.
|
||||||
|
in := make([]recommendation.Candidate, 0, 12)
|
||||||
|
seedOf := map[pgtype.UUID]int{}
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
c := makeCand(i+1, i+1, i+1, float64(12-i))
|
||||||
|
si := 0
|
||||||
|
if i == 11 {
|
||||||
|
si = 1 // the worst-ranked candidate is seed 1's only one
|
||||||
|
}
|
||||||
|
seedOf[c.Track.ID] = si
|
||||||
|
in = append(in, c)
|
||||||
|
}
|
||||||
|
head := pickQuotaHead(in, seedOf, 2, 10)
|
||||||
|
if len(head) != 10 {
|
||||||
|
t.Fatalf("head len = %d, want 10", len(head))
|
||||||
|
}
|
||||||
|
// Quotas [6,4]: seed 0 fills 6 (tracks 1-6), seed 1 fills 1
|
||||||
|
// (track 12), spill tops up with tracks 7-9.
|
||||||
|
got := map[byte]bool{}
|
||||||
|
for _, c := range head {
|
||||||
|
got[c.Track.ID.Bytes[15]] = true
|
||||||
|
}
|
||||||
|
for _, w := range []byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 12} {
|
||||||
|
if !got[w] {
|
||||||
|
t.Errorf("head missing track %d (spill should fill best-first)", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedTail_PrefersEarlyRanks(t *testing.T) {
|
||||||
|
// The fresh tail's sample is rank-weighted (#1269): across many
|
||||||
|
// days, the mean selected rank must sit well below the uniform
|
||||||
|
// draw's ~99.5 for a 200-deep pool.
|
||||||
|
pool := make([]recommendation.Candidate, 0, 200)
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
c := makeCand(1, i+1, i+1, 1.0)
|
||||||
|
// Distinct track IDs beyond one byte: bytes wrap at 256.
|
||||||
|
c.Track.ID.Bytes[14] = byte(i / 200)
|
||||||
|
c.Track.ID.Bytes[15] = byte(i % 200)
|
||||||
|
pool = append(pool, c)
|
||||||
|
}
|
||||||
|
rankOf := map[pgtype.UUID]int{}
|
||||||
|
for i, c := range pool {
|
||||||
|
rankOf[c.Track.ID] = i
|
||||||
|
}
|
||||||
|
totalRank, picks := 0, 0
|
||||||
|
for day := 1; day <= 28; day++ {
|
||||||
|
got := pickWeightedTail(pool, "2026-06-"+twoDigits(day), 10)
|
||||||
|
if len(got) != 10 {
|
||||||
|
t.Fatalf("day %d: len = %d, want 10", day, len(got))
|
||||||
|
}
|
||||||
|
for _, c := range got {
|
||||||
|
totalRank += rankOf[c.Track.ID]
|
||||||
|
picks++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mean := float64(totalRank) / float64(picks)
|
||||||
|
if mean > 80 {
|
||||||
|
t.Errorf("mean selected rank = %.1f; want well below uniform ~99.5", mean)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickWeightedTail_DeterministicWithinDay(t *testing.T) {
|
||||||
|
pool := make([]recommendation.Candidate, 0, 60)
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
pool = append(pool, makeCand(i+1, i+1, i+1, 1.0))
|
||||||
|
}
|
||||||
|
a := pickWeightedTail(pool, "2026-06-05", 8)
|
||||||
|
b := pickWeightedTail(pool, "2026-06-05", 8)
|
||||||
|
for i := range a {
|
||||||
|
if a[i].Track.ID != b[i].Track.ID {
|
||||||
|
t.Fatal("weighted tail must be deterministic within a day")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiv
|
|||||||
// build can read a fresh profile (phase-2 consumption); both steps are
|
// 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.
|
// 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) {
|
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 {
|
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, currentTasteConfig()); err != nil {
|
||||||
s.logger.Warn("scheduler: taste profile rebuild failed",
|
s.logger.Warn("scheduler: taste profile rebuild failed",
|
||||||
"user_id", uuidStringPL(userID), "err", err)
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package playlists
|
package playlists
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -46,6 +47,26 @@ func TestPickSeedArtistsFromRows_Empty(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPickKindForSeedTier(t *testing.T) {
|
||||||
|
// The seed query's fallback tiers map onto the rule-#131 ladder:
|
||||||
|
// fresh 7-day engagement is the exact desire, everything past the
|
||||||
|
// 30-day step-back collapses into the far tier (#1255).
|
||||||
|
cases := []struct {
|
||||||
|
tier int32
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{0, pickKindTier1},
|
||||||
|
{1, pickKindTier2},
|
||||||
|
{2, pickKindTier3},
|
||||||
|
{3, pickKindTier3},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := pickKindForSeedTier(c.tier); got != c.want {
|
||||||
|
t.Errorf("pickKindForSeedTier(%d) = %q, want %q", c.tier, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// userIDHash is the per-user, per-day hash that drives the daily-
|
// userIDHash is the per-user, per-day hash that drives the daily-
|
||||||
// determinism RNGs. Same family as tieBreakHash, just keyed on user
|
// determinism RNGs. Same family as tieBreakHash, just keyed on user
|
||||||
// ID instead of track ID.
|
// ID instead of track ID.
|
||||||
@@ -76,69 +97,13 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickForYouSeedForDay rotates the chosen For-You seed across the
|
// pickDailySeeds shuffles the candidate pool daily-deterministically
|
||||||
// user's top-played candidates using userIDHash. Verifies the picker
|
// and takes up to n — For-You uses n=forYouSeedCount for its
|
||||||
// is deterministic within a day, varies across days, and degrades
|
// multi-seed blend (#1269), Songs-like uses n=songsLikeSeedCount via
|
||||||
// gracefully when fewer than 5 candidates exist.
|
// the pickSeedArtistsForDay wrapper. Verifies determinism within a day,
|
||||||
|
// variation across days, and graceful degradation on small pools.
|
||||||
|
|
||||||
func TestPickForYouSeedForDay_DeterministicWithinDay(t *testing.T) {
|
func TestPickDailySeeds_DeterministicWithinDay(t *testing.T) {
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
||||||
seeds := []pgtype.UUID{
|
|
||||||
{Bytes: [16]byte{10}, Valid: true},
|
|
||||||
{Bytes: [16]byte{20}, Valid: true},
|
|
||||||
{Bytes: [16]byte{30}, Valid: true},
|
|
||||||
{Bytes: [16]byte{40}, Valid: true},
|
|
||||||
{Bytes: [16]byte{50}, Valid: true},
|
|
||||||
}
|
|
||||||
a := pickForYouSeedForDay(seeds, u, "2026-05-04")
|
|
||||||
b := pickForYouSeedForDay(seeds, u, "2026-05-04")
|
|
||||||
if a != b {
|
|
||||||
t.Fatalf("same day should pick same seed; got %v then %v", a, b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPickForYouSeedForDay_VariesAcrossDays(t *testing.T) {
|
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
||||||
seeds := []pgtype.UUID{
|
|
||||||
{Bytes: [16]byte{10}, Valid: true},
|
|
||||||
{Bytes: [16]byte{20}, Valid: true},
|
|
||||||
{Bytes: [16]byte{30}, Valid: true},
|
|
||||||
{Bytes: [16]byte{40}, Valid: true},
|
|
||||||
{Bytes: [16]byte{50}, Valid: true},
|
|
||||||
}
|
|
||||||
picks := map[[16]byte]bool{}
|
|
||||||
for i := 1; i <= 30; i++ {
|
|
||||||
date := "2026-05-" + twoDigits(i)
|
|
||||||
picks[pickForYouSeedForDay(seeds, u, date).Bytes] = true
|
|
||||||
}
|
|
||||||
if len(picks) < 2 {
|
|
||||||
t.Errorf("expected >=2 distinct seeds across 30 days; got %d", len(picks))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPickForYouSeedForDay_SingleCandidate(t *testing.T) {
|
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
||||||
only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true}
|
|
||||||
got := pickForYouSeedForDay([]pgtype.UUID{only}, u, "2026-05-04")
|
|
||||||
if got != only {
|
|
||||||
t.Errorf("single-seed pool should return that seed; got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPickForYouSeedForDay_EmptyPool(t *testing.T) {
|
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
|
||||||
got := pickForYouSeedForDay(nil, u, "2026-05-04")
|
|
||||||
if got.Valid {
|
|
||||||
t.Errorf("empty pool should return zero UUID; got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickSeedArtistsForDay takes the user's top-5 candidate artists and
|
|
||||||
// returns 3 of them via daily-deterministic shuffle. Verifies the
|
|
||||||
// picker is deterministic within a day, varies across days, and
|
|
||||||
// degrades gracefully when fewer than 3 or 5 candidates exist.
|
|
||||||
|
|
||||||
func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
|
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
pool := []pgtype.UUID{
|
pool := []pgtype.UUID{
|
||||||
{Bytes: [16]byte{10}, Valid: true},
|
{Bytes: [16]byte{10}, Valid: true},
|
||||||
@@ -147,10 +112,85 @@ func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
|
|||||||
{Bytes: [16]byte{40}, Valid: true},
|
{Bytes: [16]byte{40}, Valid: true},
|
||||||
{Bytes: [16]byte{50}, Valid: true},
|
{Bytes: [16]byte{50}, Valid: true},
|
||||||
}
|
}
|
||||||
|
a := pickDailySeeds(pool, u, "2026-05-04", 3)
|
||||||
|
b := pickDailySeeds(pool, u, "2026-05-04", 3)
|
||||||
|
if len(a) != 3 || len(b) != 3 {
|
||||||
|
t.Fatalf("lens = %d, %d; want 3, 3", len(a), len(b))
|
||||||
|
}
|
||||||
|
for i := range a {
|
||||||
|
if a[i] != b[i] {
|
||||||
|
t.Fatalf("same day should pick same seeds; got %v then %v", a, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickDailySeeds_VariesAcrossDays(t *testing.T) {
|
||||||
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
|
pool := []pgtype.UUID{
|
||||||
|
{Bytes: [16]byte{10}, Valid: true},
|
||||||
|
{Bytes: [16]byte{20}, Valid: true},
|
||||||
|
{Bytes: [16]byte{30}, Valid: true},
|
||||||
|
{Bytes: [16]byte{40}, Valid: true},
|
||||||
|
{Bytes: [16]byte{50}, Valid: true},
|
||||||
|
}
|
||||||
|
firstPicks := map[[16]byte]bool{}
|
||||||
|
for i := 1; i <= 30; i++ {
|
||||||
|
date := "2026-05-" + twoDigits(i)
|
||||||
|
firstPicks[pickDailySeeds(pool, u, date, 3)[0].Bytes] = true
|
||||||
|
}
|
||||||
|
if len(firstPicks) < 2 {
|
||||||
|
t.Errorf("expected >=2 distinct lead seeds across 30 days; got %d", len(firstPicks))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickDailySeeds_SmallPool(t *testing.T) {
|
||||||
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
|
only := pgtype.UUID{Bytes: [16]byte{99}, Valid: true}
|
||||||
|
got := pickDailySeeds([]pgtype.UUID{only}, u, "2026-05-04", 3)
|
||||||
|
if len(got) != 1 || got[0] != only {
|
||||||
|
t.Errorf("single-entry pool should return just that entry; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickDailySeeds_EmptyPool(t *testing.T) {
|
||||||
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
|
if got := pickDailySeeds(nil, u, "2026-05-04", 3); len(got) != 0 {
|
||||||
|
t.Errorf("empty pool should return nothing; got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickSeedArtistsForDay takes the user's top-12 candidate artists and
|
||||||
|
// returns songsLikeSeedCount of them via daily-deterministic shuffle.
|
||||||
|
// Verifies the picker is deterministic within a day, varies across days,
|
||||||
|
// and degrades gracefully when fewer than songsLikeSeedCount candidates
|
||||||
|
// exist. bigSeedPool is a pool comfortably larger than songsLikeSeedCount
|
||||||
|
// so the "returns exactly the count" cases have room.
|
||||||
|
func bigSeedPool(n int) []pgtype.UUID {
|
||||||
|
pool := make([]pgtype.UUID, 0, n)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
pool = append(pool, pgtype.UUID{Bytes: [16]byte{byte(10 * (i + 1))}, Valid: true})
|
||||||
|
}
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSetKey sorts the picked seeds' lead bytes into a stable string so
|
||||||
|
// two picks with the same members (any order) compare equal.
|
||||||
|
func seedSetKey(seeds []pgtype.UUID) string {
|
||||||
|
bs := make([]byte, 0, len(seeds))
|
||||||
|
for _, s := range seeds {
|
||||||
|
bs = append(bs, s.Bytes[0])
|
||||||
|
}
|
||||||
|
sort.Slice(bs, func(i, j int) bool { return bs[i] < bs[j] })
|
||||||
|
return string(bs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
|
||||||
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
|
pool := bigSeedPool(10)
|
||||||
a := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
a := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
||||||
b := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
b := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
||||||
if len(a) != 3 || len(b) != 3 {
|
if len(a) != songsLikeSeedCount || len(b) != songsLikeSeedCount {
|
||||||
t.Fatalf("expected 3 seeds; got %d / %d", len(a), len(b))
|
t.Fatalf("expected %d seeds; got %d / %d", songsLikeSeedCount, len(a), len(b))
|
||||||
}
|
}
|
||||||
for i := range a {
|
for i := range a {
|
||||||
if a[i] != b[i] {
|
if a[i] != b[i] {
|
||||||
@@ -161,40 +201,23 @@ func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
|
|||||||
|
|
||||||
func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) {
|
func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) {
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
pool := []pgtype.UUID{
|
pool := bigSeedPool(10)
|
||||||
{Bytes: [16]byte{10}, Valid: true},
|
// Collect the seed set (order-independent) across many dates. With
|
||||||
{Bytes: [16]byte{20}, Valid: true},
|
// C(10, songsLikeSeedCount) combinations, 30 dates yield >=2 distinct sets.
|
||||||
{Bytes: [16]byte{30}, Valid: true},
|
seen := map[string]bool{}
|
||||||
{Bytes: [16]byte{40}, Valid: true},
|
|
||||||
{Bytes: [16]byte{50}, Valid: true},
|
|
||||||
}
|
|
||||||
// Collect the trio (as a sorted byte tuple) across many dates.
|
|
||||||
// With C(5,3) = 10 possible trios, 30 dates should yield >=2 distinct sets.
|
|
||||||
seen := map[[3]byte]bool{}
|
|
||||||
for i := 1; i <= 30; i++ {
|
for i := 1; i <= 30; i++ {
|
||||||
got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i))
|
got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i))
|
||||||
if len(got) != 3 {
|
if len(got) != songsLikeSeedCount {
|
||||||
t.Fatalf("expected 3 seeds; got %d", len(got))
|
t.Fatalf("expected %d seeds; got %d", songsLikeSeedCount, len(got))
|
||||||
}
|
}
|
||||||
// Sort the three byte values for set-equivalence comparison.
|
seen[seedSetKey(got)] = true
|
||||||
v := [3]byte{got[0].Bytes[0], got[1].Bytes[0], got[2].Bytes[0]}
|
|
||||||
if v[0] > v[1] {
|
|
||||||
v[0], v[1] = v[1], v[0]
|
|
||||||
}
|
|
||||||
if v[1] > v[2] {
|
|
||||||
v[1], v[2] = v[2], v[1]
|
|
||||||
}
|
|
||||||
if v[0] > v[1] {
|
|
||||||
v[0], v[1] = v[1], v[0]
|
|
||||||
}
|
|
||||||
seen[v] = true
|
|
||||||
}
|
}
|
||||||
if len(seen) < 2 {
|
if len(seen) < 2 {
|
||||||
t.Errorf("expected >=2 distinct seed trios across 30 days; got %d", len(seen))
|
t.Errorf("expected >=2 distinct seed sets across 30 days; got %d", len(seen))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPickSeedArtistsForDay_FewerThanFive(t *testing.T) {
|
func TestPickSeedArtistsForDay_FewerThanCount(t *testing.T) {
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
pool := []pgtype.UUID{
|
pool := []pgtype.UUID{
|
||||||
{Bytes: [16]byte{10}, Valid: true},
|
{Bytes: [16]byte{10}, Valid: true},
|
||||||
|
|||||||
+351
-88
@@ -12,8 +12,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"sort"
|
"sort"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -22,6 +24,7 @@ import (
|
|||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
|
// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
|
||||||
@@ -32,7 +35,7 @@ type seedArtistRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pickSeedArtistsFromRows projects sqlc rows into the seed list. The
|
// pickSeedArtistsFromRows projects sqlc rows into the seed list. The
|
||||||
// SQL already orders by score DESC + artist_id and LIMIT 3, so this is
|
// SQL already orders by score DESC + artist_id and LIMIT 12, so this is
|
||||||
// just a column projection — but pulling it into a function keeps the
|
// just a column projection — but pulling it into a function keeps the
|
||||||
// call-site readable and makes the post-fetch path testable without
|
// call-site readable and makes the post-fetch path testable without
|
||||||
// a database.
|
// a database.
|
||||||
@@ -83,32 +86,15 @@ func userIDHash(userID pgtype.UUID, dateStr string) uint64 {
|
|||||||
return binary.BigEndian.Uint64(sum[:8])
|
return binary.BigEndian.Uint64(sum[:8])
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickForYouSeedForDay picks one of the user's top-played candidate
|
// pickDailySeeds takes a candidate pool of UUIDs and returns up to n
|
||||||
// tracks as today's For-You seed. With 5 candidates and SHA-256-based
|
// of them, daily-deterministically shuffled. Each day gets a different
|
||||||
// rotation, each candidate gets picked roughly 1 day in 5; the chosen
|
// ordering / selection, but within-day stability is preserved (same
|
||||||
// seed drives the similarity candidate pool, so the resulting mix is
|
// inputs always produce the same output).
|
||||||
// substantially different across days for a user with diverse top-N
|
|
||||||
// plays.
|
|
||||||
//
|
|
||||||
// Degrades gracefully: 1 candidate → returns it; 0 → returns zero UUID
|
|
||||||
// (caller treats as "no seed available" and skips For-You).
|
|
||||||
func pickForYouSeedForDay(seeds []pgtype.UUID, userID pgtype.UUID, dateStr string) pgtype.UUID {
|
|
||||||
if len(seeds) == 0 {
|
|
||||||
return pgtype.UUID{}
|
|
||||||
}
|
|
||||||
idx := int(userIDHash(userID, dateStr) % uint64(len(seeds)))
|
|
||||||
return seeds[idx]
|
|
||||||
}
|
|
||||||
|
|
||||||
// pickSeedArtistsForDay takes a candidate pool of up to N artists and
|
|
||||||
// returns up to 3 of them, daily-deterministically shuffled. Each day
|
|
||||||
// gets a different ordering / selection, but within-day stability is
|
|
||||||
// preserved (same inputs always produce the same output).
|
|
||||||
//
|
//
|
||||||
// Uses Fisher-Yates over a copy of the input, seeded by userIDHash.
|
// Uses Fisher-Yates over a copy of the input, seeded by userIDHash.
|
||||||
// Degrades gracefully: <3 candidates returns whatever is available in
|
// Degrades gracefully: <n candidates returns whatever is available in
|
||||||
// shuffled order.
|
// shuffled order.
|
||||||
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
|
func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n int) []pgtype.UUID {
|
||||||
if len(pool) == 0 {
|
if len(pool) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -118,41 +104,148 @@ func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr strin
|
|||||||
rng.Shuffle(len(shuffled), func(i, j int) {
|
rng.Shuffle(len(shuffled), func(i, j int) {
|
||||||
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
|
shuffled[i], shuffled[j] = shuffled[j], shuffled[i]
|
||||||
})
|
})
|
||||||
n := 3
|
|
||||||
if len(shuffled) < n {
|
if len(shuffled) < n {
|
||||||
n = len(shuffled)
|
n = len(shuffled)
|
||||||
}
|
}
|
||||||
return shuffled[:n]
|
return shuffled[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// songsLikeSeedCount is how many "Songs like {artist}" mixes to build
|
||||||
|
// each day (#1491). Bumped from 3 when Songs-like was promoted to its
|
||||||
|
// own dedicated Home row — the best-performing surface (low skip / high
|
||||||
|
// completion) was buried as 3 tiles in the shared Playlists carousel, so
|
||||||
|
// the wider row now shows a wider spread. Drawn from PickSeedArtists'
|
||||||
|
// deeper top-12 pool via a daily-deterministic shuffle so the set still
|
||||||
|
// rotates day to day rather than pinning the same six artists.
|
||||||
|
const songsLikeSeedCount = 6
|
||||||
|
|
||||||
|
// pickSeedArtistsForDay picks up to songsLikeSeedCount seed artists for
|
||||||
|
// the Songs-like mixes; For-You uses pickDailySeeds directly with
|
||||||
|
// forYouSeedCount.
|
||||||
|
func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr string) []pgtype.UUID {
|
||||||
|
return pickDailySeeds(pool, userID, dateStr, songsLikeSeedCount)
|
||||||
|
}
|
||||||
|
|
||||||
// rankedCandidate is a (track_id, score) pair used during in-memory
|
// rankedCandidate is a (track_id, score) pair used during in-memory
|
||||||
// sorting before insert into playlist_tracks. T5 fills these from
|
// sorting before insert into playlist_tracks. T5 fills these from
|
||||||
// recommendation.Candidate scores.
|
// recommendation.Candidate scores. PickKind is the track's provenance
|
||||||
|
// within its mix — For-You's head/tail split (#1249) and Discover's
|
||||||
|
// buckets (#1270); empty persists as NULL (variant doesn't stamp yet).
|
||||||
type rankedCandidate struct {
|
type rankedCandidate struct {
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
Score float64
|
Score float64
|
||||||
|
PickKind string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
|
||||||
|
// attribute skips to the population that sourced the track — For You's
|
||||||
|
// "taste engine missing" vs "freshness tax", Discover's three buckets
|
||||||
|
// (#1270). Values mirror the CHECK in migration 0041; taste_unheard is
|
||||||
|
// Discover's taste-targeted-novelty arm (#1488).
|
||||||
|
const (
|
||||||
|
pickKindTaste = "taste"
|
||||||
|
pickKindFresh = "fresh"
|
||||||
|
pickKindDormant = "dormant"
|
||||||
|
pickKindTasteUnheard = "taste_unheard"
|
||||||
|
pickKindCrossUser = "cross_user"
|
||||||
|
pickKindRandom = "random"
|
||||||
|
pickKindTier1 = "tier1"
|
||||||
|
pickKindTier2 = "tier2"
|
||||||
|
pickKindTier3 = "tier3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier
|
||||||
|
// onto the rule-#131 pick-kind ladder: fresh 7-day engagement pins the
|
||||||
|
// mix's exact desire (tier1), the 30-day window steps back a little
|
||||||
|
// (tier2), and all-time / liked-only seeds are the far fallback
|
||||||
|
// (tier3). Stamped mix-wide — seed staleness is a property of the
|
||||||
|
// whole build, and it's what the metrics breakdown attributes skips to.
|
||||||
|
func pickKindForSeedTier(tier int32) string {
|
||||||
|
switch tier {
|
||||||
|
case 0:
|
||||||
|
return pickKindTier1
|
||||||
|
case 1:
|
||||||
|
return pickKindTier2
|
||||||
|
default:
|
||||||
|
return pickKindTier3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindForMixTier maps a tiered mix query's 1-based tier column
|
||||||
|
// (numbered to match rule #131's ladder directly) onto the pick-kind
|
||||||
|
// vocabulary. Distinct from pickKindForSeedTier, whose 0-based tiers
|
||||||
|
// count fallback steps of the seed pool rather than eligibility rungs.
|
||||||
|
func pickKindForMixTier(tier int32) string {
|
||||||
|
switch tier {
|
||||||
|
case 1:
|
||||||
|
return pickKindTier1
|
||||||
|
case 2:
|
||||||
|
return pickKindTier2
|
||||||
|
default:
|
||||||
|
return pickKindTier3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const systemMixLength = 25
|
const systemMixLength = 25
|
||||||
|
|
||||||
// systemMixWeights are the fixed scoring weights used by the cron worker.
|
// systemMixWeights are the scoring weights used by the daily builds.
|
||||||
// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
|
// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
|
||||||
// RNG (see scoreAndSortCandidates) — same (user, day) produces same
|
// RNG (see scoreAndSortCandidates) — same (user, day) produces same
|
||||||
// scores within a day, but near-tied candidates reshuffle across days
|
// scores within a day, but near-tied candidates reshuffle across days
|
||||||
// so the playlist doesn't feel frozen.
|
// so the playlist doesn't feel frozen.
|
||||||
var systemMixWeights = recommendation.ScoringWeights{
|
//
|
||||||
BaseWeight: 1.0,
|
// DB-tunable since #1250: the recsettings service pushes the current
|
||||||
LikeBoost: 2.0,
|
// daily_mix profile via SetSystemMixWeights at boot and on every admin
|
||||||
RecencyWeight: 1.0,
|
// change (coverart Configure() pattern — no signature threading, live
|
||||||
SkipPenalty: 2.0,
|
// effect without restart). The literal here is only the pre-push
|
||||||
JitterMagnitude: 0.1,
|
// value; shipped defaults live in recsettings.ShippedDailyMixWeights,
|
||||||
ContextWeight: 0.5,
|
// which must stay in sync with it.
|
||||||
SimilarityWeight: 1.5,
|
var (
|
||||||
// Taste profile (#796 phase 2): the daily mixes are the primary
|
systemTuningMu sync.RWMutex
|
||||||
// taste-driven surface, so they lean on it. TasteMatchScore is in
|
systemMixWeights = recommendation.ScoringWeights{
|
||||||
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
BaseWeight: 1.0,
|
||||||
// while passive avoidance (negative) gently demotes.
|
LikeBoost: 2.0,
|
||||||
TasteWeight: 1.5,
|
RecencyWeight: 1.0,
|
||||||
|
SkipPenalty: 2.0,
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
systemTasteConfig = taste.DefaultConfig()
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetSystemMixWeights installs the current daily_mix scoring weights.
|
||||||
|
// Called by the recsettings service at boot and on admin updates.
|
||||||
|
func SetSystemMixWeights(w recommendation.ScoringWeights) {
|
||||||
|
systemTuningMu.Lock()
|
||||||
|
defer systemTuningMu.Unlock()
|
||||||
|
systemMixWeights = w
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTasteConfig installs the taste-profile build configuration
|
||||||
|
// (half-life + engagement curve, #1250). Same push model as
|
||||||
|
// SetSystemMixWeights.
|
||||||
|
func SetTasteConfig(c taste.Config) {
|
||||||
|
systemTuningMu.Lock()
|
||||||
|
defer systemTuningMu.Unlock()
|
||||||
|
systemTasteConfig = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentSystemMixWeights() recommendation.ScoringWeights {
|
||||||
|
systemTuningMu.RLock()
|
||||||
|
defer systemTuningMu.RUnlock()
|
||||||
|
return systemMixWeights
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentTasteConfig() taste.Config {
|
||||||
|
systemTuningMu.RLock()
|
||||||
|
defer systemTuningMu.RUnlock()
|
||||||
|
return systemTasteConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// forYouHeadN is the number of top-scored tracks that anchor the For-You
|
// forYouHeadN is the number of top-scored tracks that anchor the For-You
|
||||||
@@ -175,6 +268,122 @@ const (
|
|||||||
forYouTailN = 50
|
forYouTailN = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// forYouSeedCount is how many of the user's top-5 tracks seed each
|
||||||
|
// day's build (#1269). A single rotating seed made the mix bipolar
|
||||||
|
// day-to-day — swinging whole neighborhoods as the rotation moved
|
||||||
|
// between dissimilar anchors; blending 3 spans neighborhoods within
|
||||||
|
// one day and smooths the swing across days.
|
||||||
|
const forYouSeedCount = 3
|
||||||
|
|
||||||
|
// headQuotas fixes each seed's share of the For-You head so one
|
||||||
|
// neighborhood can't monopolize it even when its candidates out-score
|
||||||
|
// the others (#1269): 50/30/20 for three seeds, 60/40 for two, all of
|
||||||
|
// it for one. Rounding remainder goes to the primary seed; a thin
|
||||||
|
// seed's unfilled quota spills best-first in pickQuotaHead.
|
||||||
|
func headQuotas(numSeeds, headN int) []int {
|
||||||
|
if numSeeds <= 1 {
|
||||||
|
return []int{headN}
|
||||||
|
}
|
||||||
|
fractions := []float64{0.5, 0.3, 0.2}
|
||||||
|
if numSeeds == 2 {
|
||||||
|
fractions = []float64{0.6, 0.4}
|
||||||
|
}
|
||||||
|
if numSeeds > len(fractions) {
|
||||||
|
// Not reachable at forYouSeedCount = 3; even split keeps any
|
||||||
|
// future seed-count change from silently starving seeds.
|
||||||
|
fractions = make([]float64, numSeeds)
|
||||||
|
for i := range fractions {
|
||||||
|
fractions[i] = 1.0 / float64(numSeeds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
quotas := make([]int, numSeeds)
|
||||||
|
assigned := 0
|
||||||
|
for i := 0; i < numSeeds; i++ {
|
||||||
|
quotas[i] = int(float64(headN) * fractions[i])
|
||||||
|
assigned += quotas[i]
|
||||||
|
}
|
||||||
|
quotas[0] += headN - assigned
|
||||||
|
return quotas
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickQuotaHead walks the score-sorted capped pool best-first, taking
|
||||||
|
// candidates whose originating seed still has head quota; slots a thin
|
||||||
|
// seed can't fill spill best-first regardless of seed in a second
|
||||||
|
// pass. A nil seedOf (single-seed path, tests) attributes everything
|
||||||
|
// to seed 0, which reduces to plain top-headN.
|
||||||
|
func pickQuotaHead(capped []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds, headN int) []recommendation.Candidate {
|
||||||
|
quotas := headQuotas(numSeeds, headN)
|
||||||
|
taken := make([]int, len(quotas))
|
||||||
|
inHead := make(map[pgtype.UUID]bool, headN)
|
||||||
|
head := make([]recommendation.Candidate, 0, headN)
|
||||||
|
for _, c := range capped {
|
||||||
|
if len(head) >= headN {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
si := seedOf[c.Track.ID]
|
||||||
|
if si < 0 || si >= len(quotas) {
|
||||||
|
si = 0
|
||||||
|
}
|
||||||
|
if taken[si] >= quotas[si] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
taken[si]++
|
||||||
|
head = append(head, c)
|
||||||
|
inHead[c.Track.ID] = true
|
||||||
|
}
|
||||||
|
for _, c := range capped {
|
||||||
|
if len(head) >= headN {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if inHead[c.Track.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
head = append(head, c)
|
||||||
|
inHead[c.Track.ID] = true
|
||||||
|
}
|
||||||
|
return head
|
||||||
|
}
|
||||||
|
|
||||||
|
// forYouTailHalfLifeRanks tunes the fresh tail's rank bias (#1269): a
|
||||||
|
// candidate's sampling weight halves every 50 ranks, so the 101st-best
|
||||||
|
// candidate is far likelier than the 401st-best instead of equal —
|
||||||
|
// freshness keeps its "you'll probably enjoy this" half.
|
||||||
|
const forYouTailHalfLifeRanks = 50.0
|
||||||
|
|
||||||
|
// tailSampleUniverse quantizes tieBreakHash into a uniform (0,1] draw
|
||||||
|
// for the weighted sample; 1e6 buckets is plenty of resolution for
|
||||||
|
// pools of a few hundred.
|
||||||
|
const tailSampleUniverse uint64 = 1_000_000
|
||||||
|
|
||||||
|
// pickWeightedTail samples up to tailN candidates from the rank-
|
||||||
|
// ordered tail pool with exponentially rank-decaying weights, using
|
||||||
|
// deterministic Efraimidis-Spirakis keys (u^(1/w), u derived from
|
||||||
|
// tieBreakHash): same (track, day) → same key, so the sample is stable
|
||||||
|
// within a day and rotates across days. Replaces the uniform daily
|
||||||
|
// draw where rank 380 had the same chance as rank 101.
|
||||||
|
func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN int) []recommendation.Candidate {
|
||||||
|
if len(tailPool) <= tailN {
|
||||||
|
return tailPool
|
||||||
|
}
|
||||||
|
type keyed struct {
|
||||||
|
c recommendation.Candidate
|
||||||
|
key float64
|
||||||
|
}
|
||||||
|
keys := make([]keyed, len(tailPool))
|
||||||
|
for i, c := range tailPool {
|
||||||
|
u := (float64(tieBreakHash(c.Track.ID, dateStr)%tailSampleUniverse) + 1) /
|
||||||
|
float64(tailSampleUniverse+1)
|
||||||
|
w := math.Exp2(-float64(i) / forYouTailHalfLifeRanks)
|
||||||
|
keys[i] = keyed{c: c, key: math.Pow(u, 1/w)}
|
||||||
|
}
|
||||||
|
sort.SliceStable(keys, func(i, j int) bool { return keys[i].key > keys[j].key })
|
||||||
|
out := make([]recommendation.Candidate, tailN)
|
||||||
|
for i := 0; i < tailN; i++ {
|
||||||
|
out[i] = keys[i].c
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// scoreAndSortCandidates scores every candidate with recommendation.Score
|
// scoreAndSortCandidates scores every candidate with recommendation.Score
|
||||||
// and returns a new slice sorted by score DESC (ties broken by
|
// and returns a new slice sorted by score DESC (ties broken by
|
||||||
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
|
// tieBreakHash). The scoring RNG is seeded by userIDHash so jitter is
|
||||||
@@ -199,9 +408,10 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
|
|||||||
sort.SliceStable(ordered, func(i, j int) bool {
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
|
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
|
||||||
})
|
})
|
||||||
|
weights := currentSystemMixWeights()
|
||||||
pairs := make([]scored, len(ordered))
|
pairs := make([]scored, len(ordered))
|
||||||
for i, c := range ordered {
|
for i, c := range ordered {
|
||||||
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)}
|
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)}
|
||||||
}
|
}
|
||||||
sort.SliceStable(pairs, func(i, j int) bool {
|
sort.SliceStable(pairs, func(i, j int) bool {
|
||||||
if pairs[i].score != pairs[j].score {
|
if pairs[i].score != pairs[j].score {
|
||||||
@@ -336,10 +546,11 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// produceForYou: today's seed from the user's top-5 played tracks
|
// produceForYou: blend candidate pools from up to forYouSeedCount of
|
||||||
// (rotates daily via userIDHash), similarity candidate pool, head+
|
// the user's top-5 played tracks (rotating daily via pickDailySeeds),
|
||||||
// tail composition. The base seed query failing is fatal; a
|
// then head+tail composition with per-seed head quotas (#1269). The
|
||||||
// candidate-load failure is logged and yields no For-You.
|
// base seed query failing is fatal; a per-seed candidate-load failure
|
||||||
|
// is logged and that seed just contributes nothing.
|
||||||
func produceForYou(
|
func produceForYou(
|
||||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||||
userID pgtype.UUID, dateStr string, now time.Time,
|
userID pgtype.UUID, dateStr string, now time.Time,
|
||||||
@@ -348,33 +559,52 @@ func produceForYou(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("pick for-you seed candidates: %w", err)
|
return nil, fmt.Errorf("pick for-you seed candidates: %w", err)
|
||||||
}
|
}
|
||||||
forYouSeed := pickForYouSeedForDay(forYouSeeds, userID, dateStr)
|
seeds := pickDailySeeds(forYouSeeds, userID, dateStr, forYouSeedCount)
|
||||||
if !forYouSeed.Valid {
|
if len(seeds) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
zeroVec := recommendation.SessionVector{Seed: true}
|
zeroVec := recommendation.SessionVector{Seed: true}
|
||||||
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
|
// Merge per-seed pools; first-seen wins on dedup, and seedOf
|
||||||
ctx, q, userID, forYouSeed,
|
// remembers which seed sourced each track for the head quotas.
|
||||||
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
|
var merged []recommendation.Candidate
|
||||||
zeroVec,
|
seedOf := map[pgtype.UUID]int{}
|
||||||
[]pgtype.UUID{forYouSeed},
|
for i, seed := range seeds {
|
||||||
systemForYouSourceLimits(),
|
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
|
||||||
)
|
ctx, q, userID, seed,
|
||||||
if cerr != nil {
|
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
|
||||||
logger.Warn("system playlist: for-you candidates load failed; skipping",
|
zeroVec,
|
||||||
"user_id", uuidStringPL(userID), "err", cerr)
|
seeds,
|
||||||
|
systemForYouSourceLimits(),
|
||||||
|
)
|
||||||
|
if cerr != nil {
|
||||||
|
logger.Warn("system playlist: for-you candidates load failed for seed; continuing",
|
||||||
|
"user_id", uuidStringPL(userID), "seed", uuidStringPL(seed), "err", cerr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range cands {
|
||||||
|
if _, seen := seedOf[c.Track.ID]; seen {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seedOf[c.Track.ID] = i
|
||||||
|
merged = append(merged, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(merged) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
tracks := pickHeadAndTail(cands, userID, dateStr, now, forYouHeadN, forYouTailN)
|
tracks := pickHeadAndTail(merged, seedOf, len(seeds), userID, dateStr, now, forYouHeadN, forYouTailN)
|
||||||
if len(tracks) == 0 {
|
if len(tracks) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return []builtPlaylist{{Name: "For You", Variant: "for_you", Tracks: tracks}}, nil
|
return []builtPlaylist{{Name: "For You", Variant: "for_you", Tracks: tracks}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed
|
// produceSeedMixes: up to songsLikeSeedCount "Songs like {artist}"
|
||||||
// artists rotate daily-deterministically. The base seed-artist
|
// mixes. Seed artists rotate daily-deterministically; the seed query falls back
|
||||||
// query failing is fatal; per-artist failures are logged + skipped.
|
// through widening engagement windows (#1255) and every returned row
|
||||||
|
// shares the winning tier, stamped onto the built tracks as their
|
||||||
|
// pick_kind. The base seed-artist query failing is fatal; per-artist
|
||||||
|
// failures are logged + skipped.
|
||||||
func produceSeedMixes(
|
func produceSeedMixes(
|
||||||
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
ctx context.Context, q *dbq.Queries, logger *slog.Logger,
|
||||||
userID pgtype.UUID, dateStr string, now time.Time,
|
userID pgtype.UUID, dateStr string, now time.Time,
|
||||||
@@ -383,6 +613,10 @@ func produceSeedMixes(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("pick seed artists: %w", err)
|
return nil, fmt.Errorf("pick seed artists: %w", err)
|
||||||
}
|
}
|
||||||
|
seedTierKind := ""
|
||||||
|
if len(seedRows) > 0 {
|
||||||
|
seedTierKind = pickKindForSeedTier(seedRows[0].Tier)
|
||||||
|
}
|
||||||
seedRowsLocal := make([]seedArtistRow, 0, len(seedRows))
|
seedRowsLocal := make([]seedArtistRow, 0, len(seedRows))
|
||||||
for _, r := range seedRows {
|
for _, r := range seedRows {
|
||||||
seedRowsLocal = append(seedRowsLocal, seedArtistRow{
|
seedRowsLocal = append(seedRowsLocal, seedArtistRow{
|
||||||
@@ -427,6 +661,9 @@ func produceSeedMixes(
|
|||||||
if len(tracks) == 0 {
|
if len(tracks) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
for i := range tracks {
|
||||||
|
tracks[i].PickKind = seedTierKind
|
||||||
|
}
|
||||||
out = append(out, builtPlaylist{
|
out = append(out, builtPlaylist{
|
||||||
Name: fmt.Sprintf("Songs like %s", artistRow.Name),
|
Name: fmt.Sprintf("Songs like %s", artistRow.Name),
|
||||||
Variant: "songs_like_artist",
|
Variant: "songs_like_artist",
|
||||||
@@ -458,7 +695,7 @@ func produceDiscover(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
|
// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
|
||||||
// up to 3 Songs-like-{seed} mixes). Atomic-replace inside one tx;
|
// up to songsLikeSeedCount Songs-like-{seed} mixes). Atomic-replace inside one tx;
|
||||||
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
|
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
|
||||||
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
|
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
|
||||||
//
|
//
|
||||||
@@ -601,20 +838,22 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
|
|||||||
capped = capped[:n]
|
capped = capped[:n]
|
||||||
}
|
}
|
||||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||||
|
weights := currentSystemMixWeights()
|
||||||
out := make([]rankedCandidate, len(capped))
|
out := make([]rankedCandidate, len(capped))
|
||||||
for i, c := range capped {
|
for i, c := range capped {
|
||||||
out[i] = rankedCandidate{
|
out[i] = rankedCandidate{
|
||||||
TrackID: c.Track.ID,
|
TrackID: c.Track.ID,
|
||||||
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
|
Score: recommendation.Score(c.Inputs, weights, now, rng.Float64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickHeadAndTail picks headN from the score-sorted head plus tailN from
|
// pickHeadAndTail picks headN taste anchors from the score-sorted pool
|
||||||
// positions 2*headN onward (the tail), with the tail sampled
|
// (under per-seed quotas when seedOf/numSeeds describe a multi-seed
|
||||||
// daily-deterministically via tieBreakHash. Caps applied before the
|
// blend, #1269) plus tailN freshness picks sampled rank-weighted from
|
||||||
// head/tail split. Used by For-You only.
|
// positions 2*headN onward. Caps applied before the head/tail split.
|
||||||
|
// Used by For-You only.
|
||||||
//
|
//
|
||||||
// The "tail" — candidates ranked beyond 2*headN — is still similarity-
|
// The "tail" — candidates ranked beyond 2*headN — is still similarity-
|
||||||
// related (every candidate passed the similarity filter) but isn't among
|
// related (every candidate passed the similarity filter) but isn't among
|
||||||
@@ -624,58 +863,76 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
|
|||||||
// Falls back to standard pickTopN behavior when the candidate pool is too
|
// Falls back to standard pickTopN behavior when the candidate pool is too
|
||||||
// small to support a meaningful head/tail split (capped pool <=
|
// small to support a meaningful head/tail split (capped pool <=
|
||||||
// headN+tailN, or no candidates at or beyond position 2*headN).
|
// headN+tailN, or no candidates at or beyond position 2*headN).
|
||||||
func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int) []rankedCandidate {
|
func pickHeadAndTail(
|
||||||
|
cands []recommendation.Candidate, seedOf map[pgtype.UUID]int, numSeeds int,
|
||||||
|
userID pgtype.UUID, dateStr string, now time.Time, headN, tailN int,
|
||||||
|
) []rankedCandidate {
|
||||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
||||||
capped := capCandidatesByAlbumAndArtist(sorted)
|
capped := capCandidatesByAlbumAndArtist(sorted)
|
||||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||||
|
weights := currentSystemMixWeights()
|
||||||
|
|
||||||
total := headN + tailN
|
total := headN + tailN
|
||||||
if len(capped) <= total {
|
if len(capped) <= total {
|
||||||
// Pool too small for a head/tail split — return up to total entries.
|
// Pool too small for a head/tail split — return up to total entries.
|
||||||
|
// All marked taste: top-N-by-score IS the taste mechanism; no
|
||||||
|
// exploration sampling happens on this path.
|
||||||
if len(capped) < total {
|
if len(capped) < total {
|
||||||
total = len(capped)
|
total = len(capped)
|
||||||
}
|
}
|
||||||
out := make([]rankedCandidate, total)
|
out := make([]rankedCandidate, total)
|
||||||
for i := 0; i < total; i++ {
|
for i := 0; i < total; i++ {
|
||||||
out[i] = rankedCandidate{
|
out[i] = rankedCandidate{
|
||||||
TrackID: capped[i].Track.ID,
|
TrackID: capped[i].Track.ID,
|
||||||
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64),
|
Score: recommendation.Score(capped[i].Inputs, weights, now, rng.Float64),
|
||||||
|
PickKind: pickKindTaste,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
head := capped[:headN]
|
head := pickQuotaHead(capped, seedOf, numSeeds, headN)
|
||||||
|
inHead := make(map[pgtype.UUID]bool, len(head))
|
||||||
|
for _, c := range head {
|
||||||
|
inHead[c.Track.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
tailStart := 2 * headN
|
tailStart := 2 * headN
|
||||||
if tailStart >= len(capped) {
|
if tailStart >= len(capped) {
|
||||||
tailStart = headN
|
tailStart = headN
|
||||||
}
|
}
|
||||||
|
|
||||||
// Defensive copy so that sorting the tail pool does not mutate capped.
|
// The tail pool keeps its rank order (position drives the sampling
|
||||||
tailPool := append([]recommendation.Candidate{}, capped[tailStart:]...)
|
// weight); head members are excluded — a quota walk can reach past
|
||||||
|
// tailStart when a seed's candidates rank deep.
|
||||||
// Sort tail pool by tieBreakHash (daily-deterministic), take tailN.
|
tailPool := make([]recommendation.Candidate, 0, len(capped)-tailStart)
|
||||||
// Sample is stable across requests within a day but varies across days.
|
for _, c := range capped[tailStart:] {
|
||||||
sort.SliceStable(tailPool, func(i, j int) bool {
|
if inHead[c.Track.ID] {
|
||||||
return tieBreakHash(tailPool[i].Track.ID, dateStr) < tieBreakHash(tailPool[j].Track.ID, dateStr)
|
continue
|
||||||
})
|
}
|
||||||
tail := tailPool
|
tailPool = append(tailPool, c)
|
||||||
if len(tail) > tailN {
|
|
||||||
tail = tail[:tailN]
|
|
||||||
}
|
}
|
||||||
|
tail := pickWeightedTail(tailPool, dateStr, tailN)
|
||||||
|
|
||||||
// Combine: head order preserved (score-sorted), tail in tieBreakHash
|
// Combine: head first (score-sorted under quotas), then the fresh
|
||||||
// order. "First similar, then surprise" reads naturally in playback.
|
// sample. "First similar, then surprise" reads naturally in
|
||||||
|
// playback. Head entries are the taste picks; tail entries are the
|
||||||
|
// freshness injection (#1249) — the split the metrics page
|
||||||
|
// attributes skips to.
|
||||||
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
|
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
|
||||||
combined = append(combined, head...)
|
combined = append(combined, head...)
|
||||||
combined = append(combined, tail...)
|
combined = append(combined, tail...)
|
||||||
|
|
||||||
out := make([]rankedCandidate, len(combined))
|
out := make([]rankedCandidate, len(combined))
|
||||||
for i, c := range combined {
|
for i, c := range combined {
|
||||||
|
kind := pickKindTaste
|
||||||
|
if i >= len(head) {
|
||||||
|
kind = pickKindFresh
|
||||||
|
}
|
||||||
out[i] = rankedCandidate{
|
out[i] = rankedCandidate{
|
||||||
TrackID: c.Track.ID,
|
TrackID: c.Track.ID,
|
||||||
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
|
Score: recommendation.Score(c.Inputs, weights, now, rng.Float64),
|
||||||
|
PickKind: kind,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -709,9 +966,15 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, t := range tracks {
|
for _, t := range tracks {
|
||||||
|
var pickKind *string
|
||||||
|
if t.PickKind != "" {
|
||||||
|
k := t.PickKind
|
||||||
|
pickKind = &k
|
||||||
|
}
|
||||||
if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
|
if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
|
||||||
PlaylistID: p.ID,
|
PlaylistID: p.ID,
|
||||||
TrackID: t.TrackID,
|
TrackID: t.TrackID,
|
||||||
|
PickKind: pickKind,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
// Track may have been deleted between candidate-load and insert;
|
// Track may have been deleted between candidate-load and insert;
|
||||||
// skip silently rather than failing the whole build.
|
// skip silently rather than failing the whole build.
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ import (
|
|||||||
// Discover so shuffle-on-play has a varied pool within a day.
|
// Discover so shuffle-on-play has a varied pool within a day.
|
||||||
const discoveryMixLen = 100
|
const discoveryMixLen = 100
|
||||||
|
|
||||||
|
// Minimum viable mix sizes (issue #1246): below the floor the variant
|
||||||
|
// is withheld entirely so Home renders its "listen more to unlock"
|
||||||
|
// placeholder — a playlist with a couple of songs reads as a build
|
||||||
|
// bug, not a mix. Album-coherent mixes (NewForYou / FirstListens) get
|
||||||
|
// a lower floor because one legitimate new album (~5+ tracks) is a
|
||||||
|
// useful mix on its own; the scattered mixes need more to feel real.
|
||||||
|
const (
|
||||||
|
discoveryMixMinLen = 15
|
||||||
|
discoveryMixMinLenAlbum = 5
|
||||||
|
)
|
||||||
|
|
||||||
// discoveryMixSpec describes one discovery mix. The unified producer
|
// discoveryMixSpec describes one discovery mix. The unified producer
|
||||||
// reads the spec and runs a single code path for all variants.
|
// reads the spec and runs a single code path for all variants.
|
||||||
type discoveryMixSpec struct {
|
type discoveryMixSpec struct {
|
||||||
@@ -55,6 +66,11 @@ type discoveryMixSpec struct {
|
|||||||
// or when day-over-day stability is the intended UX (NewForYou).
|
// or when day-over-day stability is the intended UX (NewForYou).
|
||||||
dailyRotate bool
|
dailyRotate bool
|
||||||
|
|
||||||
|
// minLen is the minimum viable mix size: a finished pool below it
|
||||||
|
// is withheld (no playlist row) so the client renders the locked
|
||||||
|
// placeholder instead of a mix that looks built-wrong (#1246).
|
||||||
|
minLen int
|
||||||
|
|
||||||
// fetch returns the raw ranked rows. dateStr is supplied for
|
// fetch returns the raw ranked rows. dateStr is supplied for
|
||||||
// queries that accept it (passed as the second positional arg
|
// queries that accept it (passed as the second positional arg
|
||||||
// historically); queries that don't accept it ignore the param.
|
// historically); queries that don't accept it ignore the param.
|
||||||
@@ -79,25 +95,49 @@ func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
|
|||||||
if spec.dailyRotate {
|
if spec.dailyRotate {
|
||||||
pool = rotateForDay(pool, userID, dateStr)
|
pool = rotateForDay(pool, userID, dateStr)
|
||||||
}
|
}
|
||||||
return emit(spec.name, spec.variant, finishMix(pool, spec.diversify)), nil
|
tracks := finishMix(pool, spec.diversify)
|
||||||
|
if len(tracks) < spec.minLen {
|
||||||
|
logger.Info("system playlist: "+spec.variant+" below minimum viable size; withholding",
|
||||||
|
"user_id", uuidStringPL(userID), "pool", len(tracks), "min", spec.minLen)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return emit(spec.name, spec.variant, tracks), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotateForDay rotates pool left by a daily-deterministic offset so
|
// rotateForDay rotates the pool left by a daily-deterministic offset
|
||||||
// each day's downstream truncate-to-N surfaces a different slice of
|
// so each day's downstream truncate-to-N surfaces a different slice
|
||||||
// the pool while contiguous-block ordering inside the slice is
|
// while contiguous-block ordering inside the slice is preserved.
|
||||||
// preserved. Empty / single-element pools pass through unchanged.
|
//
|
||||||
|
// Rotation happens WITHIN each contiguous same-pick-kind block
|
||||||
|
// (#1267): tiered mixes arrive tier-ordered, and a whole-pool
|
||||||
|
// rotation would hoist tier-3 filler above tier-1's exact fits.
|
||||||
|
// Untiered pools are a single block, which reduces to the original
|
||||||
|
// whole-pool rotation (same seed, same first draw). Empty /
|
||||||
|
// single-element pools pass through unchanged.
|
||||||
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
|
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
|
||||||
n := len(pool)
|
n := len(pool)
|
||||||
if n <= 1 {
|
if n <= 1 {
|
||||||
return pool
|
return pool
|
||||||
}
|
}
|
||||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||||
offset := rng.Intn(n)
|
out := make([]discoverTrack, 0, n)
|
||||||
rotated := make([]discoverTrack, 0, n)
|
for start := 0; start < n; {
|
||||||
rotated = append(rotated, pool[offset:]...)
|
end := start + 1
|
||||||
rotated = append(rotated, pool[:offset]...)
|
for end < n && pool[end].PickKind == pool[start].PickKind {
|
||||||
return rotated
|
end++
|
||||||
|
}
|
||||||
|
block := pool[start:end]
|
||||||
|
if len(block) > 1 {
|
||||||
|
offset := rng.Intn(len(block))
|
||||||
|
out = append(out, block[offset:]...)
|
||||||
|
out = append(out, block[:offset]...)
|
||||||
|
} else {
|
||||||
|
out = append(out, block...)
|
||||||
|
}
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// discoveryMixSpecs is the concrete spec list used by the registry in
|
// discoveryMixSpecs is the concrete spec list used by the registry in
|
||||||
@@ -109,6 +149,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
|
|||||||
{
|
{
|
||||||
name: "Deep Cuts", variant: "deep_cuts",
|
name: "Deep Cuts", variant: "deep_cuts",
|
||||||
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
|
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
|
||||||
|
minLen: discoveryMixMinLen,
|
||||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
|
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
|
||||||
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
|
rows, err := q.ListDeepCutsTracks(ctx, dbq.ListDeepCutsTracksParams{
|
||||||
UserID: uid, Column2: ds,
|
UserID: uid, Column2: ds,
|
||||||
@@ -126,6 +167,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
|
|||||||
{
|
{
|
||||||
name: "Rediscover", variant: "rediscover",
|
name: "Rediscover", variant: "rediscover",
|
||||||
diversify: true, dailyRotate: true, // SQL has no date arg
|
diversify: true, dailyRotate: true, // SQL has no date arg
|
||||||
|
minLen: discoveryMixMinLen,
|
||||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
||||||
rows, err := q.ListRediscoverTracks(ctx, uid)
|
rows, err := q.ListRediscoverTracks(ctx, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -141,6 +183,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
|
|||||||
{
|
{
|
||||||
name: "New for you", variant: "new_for_you",
|
name: "New for you", variant: "new_for_you",
|
||||||
diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes
|
diversify: true, dailyRotate: true, // operator wants daily rotation on all deterministic mixes
|
||||||
|
minLen: discoveryMixMinLenAlbum,
|
||||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
||||||
rows, err := q.ListNewForYouTracks(ctx, uid)
|
rows, err := q.ListNewForYouTracks(ctx, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -148,7 +191,10 @@ var discoveryMixSpecs = []discoveryMixSpec{
|
|||||||
}
|
}
|
||||||
out := make([]discoverTrack, len(rows))
|
out := make([]discoverTrack, len(rows))
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
out[i] = discoverTrack{
|
||||||
|
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
||||||
|
PickKind: pickKindForMixTier(r.Tier),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
},
|
},
|
||||||
@@ -156,6 +202,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
|
|||||||
{
|
{
|
||||||
name: "On this day", variant: "on_this_day",
|
name: "On this day", variant: "on_this_day",
|
||||||
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
|
diversify: true, dailyRotate: false, // SQL day-keys via md5(id||$2)
|
||||||
|
minLen: discoveryMixMinLen,
|
||||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
|
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, ds string) ([]discoverTrack, error) {
|
||||||
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
|
rows, err := q.ListOnThisDayTracks(ctx, dbq.ListOnThisDayTracksParams{
|
||||||
UserID: uid, Column2: ds,
|
UserID: uid, Column2: ds,
|
||||||
@@ -173,6 +220,7 @@ var discoveryMixSpecs = []discoveryMixSpec{
|
|||||||
{
|
{
|
||||||
name: "First listens", variant: "first_listens",
|
name: "First listens", variant: "first_listens",
|
||||||
diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up
|
diversify: true, dailyRotate: true, // SQL has no date arg; daily rotate + diversity top-up
|
||||||
|
minLen: discoveryMixMinLenAlbum,
|
||||||
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
fetch: func(ctx context.Context, q *dbq.Queries, uid pgtype.UUID, _ string) ([]discoverTrack, error) {
|
||||||
rows, err := q.ListFirstListensTracks(ctx, uid)
|
rows, err := q.ListFirstListensTracks(ctx, uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -180,7 +228,10 @@ var discoveryMixSpecs = []discoveryMixSpec{
|
|||||||
}
|
}
|
||||||
out := make([]discoverTrack, len(rows))
|
out := make([]discoverTrack, len(rows))
|
||||||
for i, r := range rows {
|
for i, r := range rows {
|
||||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
out[i] = discoverTrack{
|
||||||
|
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
||||||
|
PickKind: pickKindForMixTier(r.Tier),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
},
|
},
|
||||||
@@ -217,7 +268,7 @@ func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
|
|||||||
}
|
}
|
||||||
tracks := make([]rankedCandidate, len(pool))
|
tracks := make([]rankedCandidate, len(pool))
|
||||||
for i, t := range pool {
|
for i, t := range pool {
|
||||||
tracks[i] = rankedCandidate{TrackID: t.ID}
|
tracks[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind}
|
||||||
}
|
}
|
||||||
return tracks
|
return tracks
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package playlists_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedTrackForArtist creates another track under an existing artist +
|
||||||
|
// album pair. seedTrack's mbid-less upsert never dedupes artists, and
|
||||||
|
// Deep Cuts' affinity rule needs several tracks sharing one artist_id.
|
||||||
|
func seedTrackForArtist(t *testing.T, pool *pgxpool.Pool, title string, albumID, artistID pgtype.UUID) dbq.Track {
|
||||||
|
t.Helper()
|
||||||
|
track, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
|
||||||
|
Title: title, AlbumID: albumID, ArtistID: artistID,
|
||||||
|
DurationMs: 1000,
|
||||||
|
FilePath: filepath.Join(t.TempDir(), fmt.Sprintf("%s.mp3", title)),
|
||||||
|
FileSize: 100, FileFormat: "mp3",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed track for artist: %v", err)
|
||||||
|
}
|
||||||
|
return track
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSourcedPlayEvent is seedPlayEvent plus a source tag — needed by
|
||||||
|
// the Rediscover cooldown, which keys off rediscover-sourced skips.
|
||||||
|
func seedSourcedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, wasSkipped bool, source string) {
|
||||||
|
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, was_skipped, source)
|
||||||
|
SELECT $1, $2, s.id, $3, $4, $5 FROM s
|
||||||
|
`, userID, trackID, startedAt, wasSkipped, source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed sourced play_event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// idSet collapses candidate rows into a lookup by track id.
|
||||||
|
func idSet[T any](rows []T, id func(T) pgtype.UUID) map[pgtype.UUID]bool {
|
||||||
|
out := make(map[pgtype.UUID]bool, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
out[id(r)] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedPlayWithDuration inserts a completed play with an explicit
|
||||||
|
// duration_played_ms — the "attempted >= 30s" queries key off it.
|
||||||
|
func seedPlayWithDuration(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, durationMs int32) {
|
||||||
|
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, duration_played_ms)
|
||||||
|
SELECT $1, $2, s.id, $3, $4 FROM s
|
||||||
|
`, userID, trackID, startedAt, durationMs)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed play with duration: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedAlbumForArtist creates a fresh album under an existing artist —
|
||||||
|
// the tiered album queries need several albums sharing one artist_id.
|
||||||
|
func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, title string, artistID pgtype.UUID) pgtype.UUID {
|
||||||
|
t.Helper()
|
||||||
|
al, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||||
|
Title: title, SortTitle: title, ArtistID: artistID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed album for artist: %v", err)
|
||||||
|
}
|
||||||
|
return al.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func backdateAlbum(t *testing.T, pool *pgxpool.Pool, albumID pgtype.UUID, to time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`UPDATE albums SET created_at = $2 WHERE id = $1`, albumID, to); err != nil {
|
||||||
|
t.Fatalf("backdate album: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`,
|
||||||
|
userID, artistID); err != nil {
|
||||||
|
t.Fatalf("like artist: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) {
|
||||||
|
// #1257: eligibility used to count only unskipped plays, so a track
|
||||||
|
// skipped repeatedly (and never finished) read as "barely heard".
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
u := seedUser(t, pool, "dc1")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Affinity artist: 5 non-skip plays on the heavy track.
|
||||||
|
heavy := seedTrack(t, pool, "dc1-heavy", "dc1-artist")
|
||||||
|
for p := 0; p < 5; p++ {
|
||||||
|
seedPlayEvent(t, pool, u.ID, heavy.ID, now.Add(-time.Duration(p+1)*time.Hour), false)
|
||||||
|
}
|
||||||
|
// Actively rejected: two skips, zero completed plays.
|
||||||
|
rejected := seedTrackForArtist(t, pool, "dc1-rejected", heavy.AlbumID, heavy.ArtistID)
|
||||||
|
seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-2*time.Hour), true)
|
||||||
|
seedPlayEvent(t, pool, u.ID, rejected.ID, now.Add(-3*time.Hour), true)
|
||||||
|
// Genuinely unexplored.
|
||||||
|
unheard := seedTrackForArtist(t, pool, "dc1-unheard", heavy.AlbumID, heavy.ArtistID)
|
||||||
|
// One accidental skip stays below the threshold.
|
||||||
|
oneSkip := seedTrackForArtist(t, pool, "dc1-oneskip", heavy.AlbumID, heavy.ArtistID)
|
||||||
|
seedPlayEvent(t, pool, u.ID, oneSkip.ID, now.Add(-4*time.Hour), true)
|
||||||
|
|
||||||
|
rows, err := q.ListDeepCutsTracks(context.Background(), dbq.ListDeepCutsTracksParams{
|
||||||
|
UserID: u.ID, Column2: "2026-07-03",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListDeepCutsTracks: %v", err)
|
||||||
|
}
|
||||||
|
got := idSet(rows, func(r dbq.ListDeepCutsTracksRow) pgtype.UUID { return r.ID })
|
||||||
|
if got[rejected.ID] {
|
||||||
|
t.Error("twice-skipped track qualified as a deep cut; skips must disqualify")
|
||||||
|
}
|
||||||
|
if !got[unheard.ID] {
|
||||||
|
t.Error("unexplored track missing from deep cuts")
|
||||||
|
}
|
||||||
|
if !got[oneSkip.ID] {
|
||||||
|
t.Error("single accidental skip must not banish a track")
|
||||||
|
}
|
||||||
|
if got[heavy.ID] {
|
||||||
|
t.Error("heavily-played track must not be a deep cut")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
|
||||||
|
// #1256: plain ABS on day-of-year made Dec 28 vs Jan 3 read as 359
|
||||||
|
// days apart. The build date parameter anchors "today" so the wrap
|
||||||
|
// is testable at any run date.
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
u := seedUser(t, pool, "otd1")
|
||||||
|
|
||||||
|
// Played Dec 28, 2025 — 6 circular days from the Jan 3 build date.
|
||||||
|
winter := seedTrack(t, pool, "otd1-winter", "otd1-artist")
|
||||||
|
seedPlayEvent(t, pool, u.ID, winter.ID,
|
||||||
|
time.Date(2025, 12, 28, 12, 0, 0, 0, time.UTC), false)
|
||||||
|
// Played mid-March — far outside the ±10-day window.
|
||||||
|
spring := seedTrack(t, pool, "otd1-spring", "otd1-artist")
|
||||||
|
seedPlayEvent(t, pool, u.ID, spring.ID,
|
||||||
|
time.Date(2025, 3, 15, 12, 0, 0, 0, time.UTC), false)
|
||||||
|
|
||||||
|
rows, err := q.ListOnThisDayTracks(context.Background(), dbq.ListOnThisDayTracksParams{
|
||||||
|
UserID: u.ID, Column2: "2026-01-03",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListOnThisDayTracks: %v", err)
|
||||||
|
}
|
||||||
|
got := idSet(rows, func(r dbq.ListOnThisDayTracksRow) pgtype.UUID { return r.ID })
|
||||||
|
if !got[winter.ID] {
|
||||||
|
t.Error("play 6 calendar days across the year boundary must qualify (circular DOY)")
|
||||||
|
}
|
||||||
|
if got[spring.ID] {
|
||||||
|
t.Error("play ~70 days away must not qualify")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) {
|
||||||
|
// #1267: tiered eligibility with consume-on-play. Tier 1 = fresh
|
||||||
|
// affinity albums; tier 2 = affinity albums from the wider 90-day
|
||||||
|
// window; tier 3 = fresh additions without affinity; any attempted
|
||||||
|
// (>=30s) track consumes the album out of the mix entirely.
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
u := seedUser(t, pool, "nfy1")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Affinity artist via a like; anchor track establishes the artist.
|
||||||
|
anchor := seedTrack(t, pool, "nfy1-anchor", "nfy1-artistX")
|
||||||
|
likeArtist(t, pool, u.ID, anchor.ArtistID)
|
||||||
|
backdateAlbum(t, pool, anchor.AlbumID, now.Add(-200*24*time.Hour)) // out of every window
|
||||||
|
|
||||||
|
// Fresh affinity album → tier 1.
|
||||||
|
freshAlbum := seedAlbumForArtist(t, pool, "nfy1-fresh", anchor.ArtistID)
|
||||||
|
freshTrack := seedTrackForArtist(t, pool, "nfy1-fresh-1", freshAlbum, anchor.ArtistID)
|
||||||
|
// Affinity album added 60 days ago → tier 2.
|
||||||
|
olderAlbum := seedAlbumForArtist(t, pool, "nfy1-older", anchor.ArtistID)
|
||||||
|
olderTrack := seedTrackForArtist(t, pool, "nfy1-older-1", olderAlbum, anchor.ArtistID)
|
||||||
|
backdateAlbum(t, pool, olderAlbum, now.Add(-60*24*time.Hour))
|
||||||
|
// Fresh album by an unrelated artist → tier 3.
|
||||||
|
otherTrack := seedTrack(t, pool, "nfy1-other-1", "nfy1-artistY")
|
||||||
|
// Fresh affinity album with one attempted track → consumed, absent.
|
||||||
|
consumedAlbum := seedAlbumForArtist(t, pool, "nfy1-consumed", anchor.ArtistID)
|
||||||
|
consumedTrack := seedTrackForArtist(t, pool, "nfy1-consumed-1", consumedAlbum, anchor.ArtistID)
|
||||||
|
seedPlayWithDuration(t, pool, u.ID, consumedTrack.ID, now.Add(-time.Hour), 35_000)
|
||||||
|
// Affinity album added 120 days ago → outside every window, absent.
|
||||||
|
ancientAlbum := seedAlbumForArtist(t, pool, "nfy1-ancient", anchor.ArtistID)
|
||||||
|
ancientTrack := seedTrackForArtist(t, pool, "nfy1-ancient-1", ancientAlbum, anchor.ArtistID)
|
||||||
|
backdateAlbum(t, pool, ancientAlbum, now.Add(-120*24*time.Hour))
|
||||||
|
|
||||||
|
rows, err := q.ListNewForYouTracks(context.Background(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListNewForYouTracks: %v", err)
|
||||||
|
}
|
||||||
|
tiers := map[pgtype.UUID]int32{}
|
||||||
|
for _, r := range rows {
|
||||||
|
tiers[r.ID] = r.Tier
|
||||||
|
}
|
||||||
|
if got := tiers[freshTrack.ID]; got != 1 {
|
||||||
|
t.Errorf("fresh affinity album tier = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := tiers[olderTrack.ID]; got != 2 {
|
||||||
|
t.Errorf("60-day affinity album tier = %d, want 2", got)
|
||||||
|
}
|
||||||
|
if got := tiers[otherTrack.ID]; got != 3 {
|
||||||
|
t.Errorf("fresh non-affinity album tier = %d, want 3", got)
|
||||||
|
}
|
||||||
|
if _, in := tiers[consumedTrack.ID]; in {
|
||||||
|
t.Error("album with an attempted track must be consumed out of the mix")
|
||||||
|
}
|
||||||
|
if _, in := tiers[ancientTrack.ID]; in {
|
||||||
|
t.Error("album added 120 days ago must be outside every window")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListFirstListensTracks_AttemptThreshold(t *testing.T) {
|
||||||
|
// #1268: "attempted" is track-level at >=30s. A 2-second brush no
|
||||||
|
// longer disqualifies an album; barely-attempted albums step back
|
||||||
|
// to tier 2 with the attempted tracks themselves excluded.
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
u := seedUser(t, pool, "fl1")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Never touched → tier 1.
|
||||||
|
untouched := seedTrack(t, pool, "fl1-untouched-1", "fl1-artistA")
|
||||||
|
// One 2-second accidental brush → still tier 1 (the headline bug).
|
||||||
|
brushed := seedTrack(t, pool, "fl1-brushed-1", "fl1-artistB")
|
||||||
|
seedPlayWithDuration(t, pool, u.ID, brushed.ID, now.Add(-time.Hour), 2_000)
|
||||||
|
// 4-track album with one attempted (25%) → tier 2, attempted track
|
||||||
|
// excluded, siblings present.
|
||||||
|
barely1 := seedTrack(t, pool, "fl1-barely-1", "fl1-artistC")
|
||||||
|
barely2 := seedTrackForArtist(t, pool, "fl1-barely-2", barely1.AlbumID, barely1.ArtistID)
|
||||||
|
barely3 := seedTrackForArtist(t, pool, "fl1-barely-3", barely1.AlbumID, barely1.ArtistID)
|
||||||
|
barely4 := seedTrackForArtist(t, pool, "fl1-barely-4", barely1.AlbumID, barely1.ArtistID)
|
||||||
|
seedPlayWithDuration(t, pool, u.ID, barely1.ID, now.Add(-time.Hour), 40_000)
|
||||||
|
// 2-track album with one attempted (50%) → past the brush
|
||||||
|
// threshold, absent entirely.
|
||||||
|
explored1 := seedTrack(t, pool, "fl1-explored-1", "fl1-artistD")
|
||||||
|
explored2 := seedTrackForArtist(t, pool, "fl1-explored-2", explored1.AlbumID, explored1.ArtistID)
|
||||||
|
seedPlayWithDuration(t, pool, u.ID, explored1.ID, now.Add(-time.Hour), 40_000)
|
||||||
|
|
||||||
|
rows, err := q.ListFirstListensTracks(context.Background(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListFirstListensTracks: %v", err)
|
||||||
|
}
|
||||||
|
tiers := map[pgtype.UUID]int32{}
|
||||||
|
for _, r := range rows {
|
||||||
|
tiers[r.ID] = r.Tier
|
||||||
|
}
|
||||||
|
if got := tiers[untouched.ID]; got != 1 {
|
||||||
|
t.Errorf("untouched album tier = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if got := tiers[brushed.ID]; got != 1 {
|
||||||
|
t.Errorf("2-second brush must not disqualify: tier = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if _, in := tiers[barely1.ID]; in {
|
||||||
|
t.Error("the attempted track itself is not a first listen")
|
||||||
|
}
|
||||||
|
for _, tr := range []dbq.Track{barely2, barely3, barely4} {
|
||||||
|
if got := tiers[tr.ID]; got != 2 {
|
||||||
|
t.Errorf("sibling of attempted track tier = %d, want 2", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, in := tiers[explored1.ID]; in {
|
||||||
|
t.Error("half-attempted album must be excluded (attempted track)")
|
||||||
|
}
|
||||||
|
if _, in := tiers[explored2.ID]; in {
|
||||||
|
t.Error("half-attempted album must be excluded (sibling)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListRediscoverTracks_SkippedResurfacingCoolsDown(t *testing.T) {
|
||||||
|
// #1258: a skip on a rediscover-sourced play is a declined
|
||||||
|
// invitation — the track sits out ~90 days instead of re-qualifying
|
||||||
|
// the next day. Skips from other surfaces don't cool down, and the
|
||||||
|
// cooldown expires.
|
||||||
|
pool := newPool(t)
|
||||||
|
q := dbq.New(pool)
|
||||||
|
u := seedUser(t, pool, "rd1")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
eligible := func(name string, playsAge time.Duration) dbq.Track {
|
||||||
|
tk := seedTrack(t, pool, name, name+"-artist")
|
||||||
|
for p := 0; p < 3; p++ {
|
||||||
|
seedPlayEvent(t, pool, u.ID, tk.ID,
|
||||||
|
now.Add(-playsAge-time.Duration(p)*time.Hour), false)
|
||||||
|
}
|
||||||
|
return tk
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declined 5 days ago from Rediscover itself → cooling down.
|
||||||
|
declined := eligible("rd1-declined", 60*24*time.Hour)
|
||||||
|
seedSourcedPlayEvent(t, pool, u.ID, declined.ID, now.Add(-5*24*time.Hour), true, "rediscover")
|
||||||
|
// Skipped 5 days ago, but from a different surface → still eligible.
|
||||||
|
otherSkip := eligible("rd1-otherskip", 60*24*time.Hour)
|
||||||
|
seedSourcedPlayEvent(t, pool, u.ID, otherSkip.ID, now.Add(-5*24*time.Hour), true, "radio")
|
||||||
|
// Declined 100 days ago → cooldown expired, eligible again.
|
||||||
|
lapsed := eligible("rd1-lapsed", 120*24*time.Hour)
|
||||||
|
seedSourcedPlayEvent(t, pool, u.ID, lapsed.ID, now.Add(-100*24*time.Hour), true, "rediscover")
|
||||||
|
|
||||||
|
rows, err := q.ListRediscoverTracks(context.Background(), u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRediscoverTracks: %v", err)
|
||||||
|
}
|
||||||
|
got := idSet(rows, func(r dbq.ListRediscoverTracksRow) pgtype.UUID { return r.ID })
|
||||||
|
if got[declined.ID] {
|
||||||
|
t.Error("track declined from rediscover 5 days ago must be cooling down")
|
||||||
|
}
|
||||||
|
if !got[otherSkip.ID] {
|
||||||
|
t.Error("skip from a non-rediscover surface must not cool the track down")
|
||||||
|
}
|
||||||
|
if !got[lapsed.ID] {
|
||||||
|
t.Error("cooldown must expire after ~90 days")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package playlists
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mixTrack(b byte, kind string) discoverTrack {
|
||||||
|
return discoverTrack{ID: pgtype.UUID{Bytes: [16]byte{b}, Valid: true}, PickKind: kind}
|
||||||
|
}
|
||||||
|
|
||||||
|
func idsOf(pool []discoverTrack) []byte {
|
||||||
|
out := make([]byte, len(pool))
|
||||||
|
for i, t := range pool {
|
||||||
|
out[i] = t.ID.Bytes[0]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRotateForDay_RotatesWithinTierBlocks(t *testing.T) {
|
||||||
|
// Tiered pools arrive tier-ordered; rotation must vary the daily
|
||||||
|
// slice WITHIN each tier without hoisting tier-3 filler above
|
||||||
|
// tier-1's exact fits (#1267).
|
||||||
|
pool := []discoverTrack{
|
||||||
|
mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier1), mixTrack(3, pickKindTier1),
|
||||||
|
mixTrack(4, pickKindTier2), mixTrack(5, pickKindTier2),
|
||||||
|
mixTrack(6, pickKindTier3),
|
||||||
|
}
|
||||||
|
u := pgtype.UUID{Bytes: [16]byte{42}, Valid: true}
|
||||||
|
got := rotateForDay(pool, u, "2026-07-03")
|
||||||
|
if len(got) != len(pool) {
|
||||||
|
t.Fatalf("len = %d, want %d", len(got), len(pool))
|
||||||
|
}
|
||||||
|
wantKinds := []string{
|
||||||
|
pickKindTier1, pickKindTier1, pickKindTier1,
|
||||||
|
pickKindTier2, pickKindTier2, pickKindTier3,
|
||||||
|
}
|
||||||
|
for i, k := range wantKinds {
|
||||||
|
if got[i].PickKind != k {
|
||||||
|
t.Fatalf("pos %d kind = %q, want %q (tier order broken: %v)",
|
||||||
|
i, got[i].PickKind, k, idsOf(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Each block is a rotation of its input: contiguity check for the
|
||||||
|
// 3-element tier-1 block (successor relation preserved cyclically).
|
||||||
|
wantOrder := map[byte]byte{1: 2, 2: 3, 3: 1}
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
if got[i+1].ID.Bytes[0] != wantOrder[got[i].ID.Bytes[0]] {
|
||||||
|
t.Errorf("tier1 block is not a rotation: %v", idsOf(got[:3]))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Determinism within a day.
|
||||||
|
again := rotateForDay(pool, u, "2026-07-03")
|
||||||
|
for i := range got {
|
||||||
|
if got[i].ID != again[i].ID {
|
||||||
|
t.Fatal("rotation must be deterministic for the same (user, day)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRotateForDay_UntieredPoolIsWholeRotation(t *testing.T) {
|
||||||
|
// Untiered pools (all PickKind "") are one block — the original
|
||||||
|
// whole-pool rotation semantics.
|
||||||
|
pool := []discoverTrack{mixTrack(1, ""), mixTrack(2, ""), mixTrack(3, ""), mixTrack(4, "")}
|
||||||
|
got := rotateForDay(pool, pgtype.UUID{Bytes: [16]byte{7}, Valid: true}, "2026-07-03")
|
||||||
|
if len(got) != 4 {
|
||||||
|
t.Fatalf("len = %d, want 4", len(got))
|
||||||
|
}
|
||||||
|
wantNext := map[byte]byte{1: 2, 2: 3, 3: 4, 4: 1}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
cur, next := got[i].ID.Bytes[0], got[i+1].ID.Bytes[0]
|
||||||
|
if wantNext[cur] != next {
|
||||||
|
t.Errorf("not a rotation of the input: %v", idsOf(got))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinishMix_PropagatesPickKind(t *testing.T) {
|
||||||
|
// Tier stamps ride discoverTrack through diversify/truncate into
|
||||||
|
// the rankedCandidates that insertSystemPlaylist persists (#1267).
|
||||||
|
pool := []discoverTrack{mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier2)}
|
||||||
|
got := finishMix(pool, false)
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("len = %d, want 2", len(got))
|
||||||
|
}
|
||||||
|
if got[0].PickKind != pickKindTier1 || got[1].PickKind != pickKindTier2 {
|
||||||
|
t.Errorf("pick kinds = %q, %q — want tier1, tier2", got[0].PickKind, got[1].PickKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPickKindForMixTier(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
tier int32
|
||||||
|
want string
|
||||||
|
}{{1, pickKindTier1}, {2, pickKindTier2}, {3, pickKindTier3}, {9, pickKindTier3}}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := pickKindForMixTier(c.tier); got != c.want {
|
||||||
|
t.Errorf("pickKindForMixTier(%d) = %q, want %q", c.tier, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,6 +135,59 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildSystemPlaylists_StaleActivity_SongsLikeSurvives(t *testing.T) {
|
||||||
|
// #1255: the seed-artist query's old hard 7-day window emptied the
|
||||||
|
// pool after a quiet week, and the atomic-replace build then deleted
|
||||||
|
// every "Songs like X" mix. With the tiered fallback, plays that are
|
||||||
|
// ~20 days old (outside 7d, inside 30d) must still seed the mixes —
|
||||||
|
// and the built tracks carry the tier2 stamp so metrics can compare
|
||||||
|
// stale-seeded mixes against fresh ones.
|
||||||
|
pool := newPool(t)
|
||||||
|
logger := discardLogger()
|
||||||
|
u := seedUser(t, pool, "stale1")
|
||||||
|
old := time.Now().UTC().Add(-20 * 24 * time.Hour)
|
||||||
|
for a := 0; a < 4; a++ {
|
||||||
|
for k := 0; k < 3; k++ {
|
||||||
|
tk := seedTrack(t, pool,
|
||||||
|
"stale1-track-"+string(rune('A'+a))+string(rune('0'+k)),
|
||||||
|
"stale1-artist-"+string(rune('A'+a)))
|
||||||
|
for p := 0; p < 3; p++ {
|
||||||
|
seedPlayEvent(t, pool, u.ID, tk.ID,
|
||||||
|
old.Add(-time.Duration(a*10+k+p)*time.Hour), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := playlists.BuildSystemPlaylists(context.Background(), pool, logger, u.ID, time.Now().UTC(), t.TempDir()); err != nil {
|
||||||
|
t.Fatalf("build: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := pool.Query(context.Background(), `
|
||||||
|
SELECT DISTINCT COALESCE(pt.pick_kind, '<null>')
|
||||||
|
FROM playlist_tracks pt
|
||||||
|
JOIN playlists p ON p.id = pt.playlist_id
|
||||||
|
WHERE p.user_id = $1 AND p.system_variant = 'songs_like_artist'
|
||||||
|
`, u.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query pick_kinds: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var kinds []string
|
||||||
|
for rows.Next() {
|
||||||
|
var k string
|
||||||
|
if err := rows.Scan(&k); err != nil {
|
||||||
|
t.Fatalf("scan: %v", err)
|
||||||
|
}
|
||||||
|
kinds = append(kinds, k)
|
||||||
|
}
|
||||||
|
if len(kinds) == 0 {
|
||||||
|
t.Fatal("expected songs_like_artist tracks from the 30-day fallback tier; got none (the vanish bug)")
|
||||||
|
}
|
||||||
|
if len(kinds) != 1 || kinds[0] != "tier2" {
|
||||||
|
t.Errorf("pick_kinds = %v, want exactly [tier2] (30-day fallback seeds)", kinds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildSystemPlaylists_QuarantineExcluded(t *testing.T) {
|
func TestBuildSystemPlaylists_QuarantineExcluded(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
logger := discardLogger()
|
logger := discardLogger()
|
||||||
|
|||||||
@@ -83,10 +83,14 @@ func buildYouMightLike(
|
|||||||
"user_id", uuidStringPL(userID), "err", err)
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
return youMightLikeResult{built: false}
|
return youMightLikeResult{built: false}
|
||||||
}
|
}
|
||||||
seed := pickForYouSeedForDay(seeds, userID, dateStr)
|
// One rotating seed is right here (unlike For-You's multi-seed
|
||||||
if !seed.Valid {
|
// blend, #1269): the row is a short shelf, not a mix, and a single
|
||||||
|
// neighborhood per day keeps it coherent.
|
||||||
|
daily := pickDailySeeds(seeds, userID, dateStr, 1)
|
||||||
|
if len(daily) == 0 {
|
||||||
return youMightLikeResult{built: true}
|
return youMightLikeResult{built: true}
|
||||||
}
|
}
|
||||||
|
seed := daily[0]
|
||||||
|
|
||||||
zeroVec := recommendation.SessionVector{Seed: true}
|
zeroVec := recommendation.SessionVector{Seed: true}
|
||||||
// You-might-like surfaces in-library artists the user does NOT actively
|
// You-might-like surfaces in-library artists the user does NOT actively
|
||||||
@@ -123,11 +127,12 @@ func rollUpCandidates(
|
|||||||
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
|
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
|
||||||
) (albumIDs, artistIDs []pgtype.UUID) {
|
) (albumIDs, artistIDs []pgtype.UUID) {
|
||||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||||
|
weights := currentSystemMixWeights()
|
||||||
albumScores := map[pgtype.UUID][]float64{}
|
albumScores := map[pgtype.UUID][]float64{}
|
||||||
artistScores := map[pgtype.UUID][]float64{}
|
artistScores := map[pgtype.UUID][]float64{}
|
||||||
albumArtist := map[pgtype.UUID]pgtype.UUID{}
|
albumArtist := map[pgtype.UUID]pgtype.UUID{}
|
||||||
for _, c := range cands {
|
for _, c := range cands {
|
||||||
s := recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)
|
s := recommendation.Score(c.Inputs, weights, now, rng.Float64)
|
||||||
if c.Track.AlbumID.Valid {
|
if c.Track.AlbumID.Valid {
|
||||||
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
|
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
|
||||||
albumArtist[c.Track.AlbumID] = c.Track.ArtistID
|
albumArtist[c.Track.AlbumID] = c.Track.ArtistID
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// patch.go — field-name mapping + validation for the tuning patches.
|
||||||
|
// Wire field names are the snake_case column names; the admin API and
|
||||||
|
// web card use them verbatim.
|
||||||
|
package recsettings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrUnknownScope = errors.New("unknown tuning scope")
|
||||||
|
ErrUnknownField = errors.New("unknown tuning field")
|
||||||
|
ErrOutOfRange = errors.New("tuning value out of range")
|
||||||
|
)
|
||||||
|
|
||||||
|
// weightBound caps every scoring weight's magnitude. The scoring
|
||||||
|
// terms are all in [-1, 1] before weighting, so ±10 is far past any
|
||||||
|
// useful setting — the bound exists to catch typos (e.g. 100 for
|
||||||
|
// 1.00), not to constrain exploration.
|
||||||
|
const weightBound = 10.0
|
||||||
|
|
||||||
|
// weightField describes one patchable ScoringWeights field.
|
||||||
|
type weightField struct {
|
||||||
|
get func(recommendation.ScoringWeights) float64
|
||||||
|
set func(*recommendation.ScoringWeights, float64)
|
||||||
|
// nonNegative marks fields where a negative value is meaningless
|
||||||
|
// (a negative jitter magnitude or skip penalty inverts intent in a
|
||||||
|
// way the score formula already expresses through its sign).
|
||||||
|
nonNegative bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var weightFields = map[string]weightField{
|
||||||
|
"base_weight": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.BaseWeight },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.BaseWeight = v },
|
||||||
|
},
|
||||||
|
"like_boost": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.LikeBoost },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.LikeBoost = v },
|
||||||
|
},
|
||||||
|
"recency_weight": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.RecencyWeight },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.RecencyWeight = v },
|
||||||
|
},
|
||||||
|
"skip_penalty": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.SkipPenalty },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.SkipPenalty = v },
|
||||||
|
nonNegative: true,
|
||||||
|
},
|
||||||
|
"jitter_magnitude": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.JitterMagnitude },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.JitterMagnitude = v },
|
||||||
|
nonNegative: true,
|
||||||
|
},
|
||||||
|
"context_weight": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.ContextWeight },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.ContextWeight = v },
|
||||||
|
},
|
||||||
|
"similarity_weight": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.SimilarityWeight },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.SimilarityWeight = v },
|
||||||
|
},
|
||||||
|
"taste_weight": {
|
||||||
|
get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
|
||||||
|
set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyWeightPatch validates and applies a partial update, returning
|
||||||
|
// the new weights and the list of actual changes (values equal to the
|
||||||
|
// current setting are dropped, so a re-submitted form is a no-op).
|
||||||
|
func applyWeightPatch(
|
||||||
|
current recommendation.ScoringWeights, patch map[string]float64,
|
||||||
|
) (recommendation.ScoringWeights, []fieldChange, error) {
|
||||||
|
next := current
|
||||||
|
var changes []fieldChange
|
||||||
|
for field, v := range patch {
|
||||||
|
f, ok := weightFields[field]
|
||||||
|
if !ok {
|
||||||
|
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||||
|
}
|
||||||
|
if v < -weightBound || v > weightBound {
|
||||||
|
return current, nil, fmt.Errorf("%w: %s = %v (|v| must be <= %v)",
|
||||||
|
ErrOutOfRange, field, v, weightBound)
|
||||||
|
}
|
||||||
|
if f.nonNegative && v < 0 {
|
||||||
|
return current, nil, fmt.Errorf("%w: %s = %v (must be >= 0)",
|
||||||
|
ErrOutOfRange, field, v)
|
||||||
|
}
|
||||||
|
old := f.get(next)
|
||||||
|
if old == v {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f.set(&next, v)
|
||||||
|
changes = append(changes, fieldChange{Field: field, Old: old, New: v})
|
||||||
|
}
|
||||||
|
return next, changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taste tuning bounds. The half-life window is generous — from "taste
|
||||||
|
// is last week" to "taste is a decade" — and the curve points must
|
||||||
|
// stay ordered inside [0, 1] or the engagement ramps degenerate.
|
||||||
|
const (
|
||||||
|
tasteHalfLifeMin = 1.0
|
||||||
|
tasteHalfLifeMax = 3650.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// applyTastePatch validates and applies a partial taste update. The
|
||||||
|
// curve-ordering invariant (hard_skip < neutral < full) is checked on
|
||||||
|
// the PATCHED result, so a patch may move several points at once.
|
||||||
|
func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning, []fieldChange, error) {
|
||||||
|
next := current
|
||||||
|
var changes []fieldChange
|
||||||
|
for field, v := range patch {
|
||||||
|
var target *float64
|
||||||
|
switch field {
|
||||||
|
case "half_life_days":
|
||||||
|
if v < tasteHalfLifeMin || v > tasteHalfLifeMax {
|
||||||
|
return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
|
||||||
|
ErrOutOfRange, field, v, tasteHalfLifeMin, tasteHalfLifeMax)
|
||||||
|
}
|
||||||
|
target = &next.HalfLifeDays
|
||||||
|
case "engagement_hard_skip":
|
||||||
|
target = &next.EngagementHardSkip
|
||||||
|
case "engagement_neutral":
|
||||||
|
target = &next.EngagementNeutral
|
||||||
|
case "engagement_full":
|
||||||
|
target = &next.EngagementFull
|
||||||
|
default:
|
||||||
|
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||||
|
}
|
||||||
|
if field != "half_life_days" && (v < 0 || v > 1) {
|
||||||
|
return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, 1])",
|
||||||
|
ErrOutOfRange, field, v)
|
||||||
|
}
|
||||||
|
if *target == v {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
|
||||||
|
*target = v
|
||||||
|
}
|
||||||
|
if !(next.EngagementHardSkip < next.EngagementNeutral &&
|
||||||
|
next.EngagementNeutral < next.EngagementFull) {
|
||||||
|
return current, nil, fmt.Errorf(
|
||||||
|
"%w: engagement curve must satisfy hard_skip < neutral < full (got %v < %v < %v)",
|
||||||
|
ErrOutOfRange, next.EngagementHardSkip, next.EngagementNeutral, next.EngagementFull)
|
||||||
|
}
|
||||||
|
return next, changes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffWeights returns per-field changes from a to b (empty when equal).
|
||||||
|
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
|
||||||
|
var out []fieldChange
|
||||||
|
for field, f := range weightFields {
|
||||||
|
if f.get(a) != f.get(b) {
|
||||||
|
out = append(out, fieldChange{Field: field, Old: f.get(a), New: f.get(b)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffTaste returns per-field changes from a to b (empty when equal).
|
||||||
|
func diffTaste(a, b TasteTuning) []fieldChange {
|
||||||
|
var out []fieldChange
|
||||||
|
add := func(field string, oldV, newV float64) {
|
||||||
|
if oldV != newV {
|
||||||
|
out = append(out, fieldChange{Field: field, Old: oldV, New: newV})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("half_life_days", a.HalfLifeDays, b.HalfLifeDays)
|
||||||
|
add("engagement_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
|
||||||
|
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
|
||||||
|
add("engagement_full", a.EngagementFull, b.EngagementFull)
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
// Package recsettings is the DB-backed home of the recommendation
|
||||||
|
// tuning knobs (#1250): the two scoring-weight profiles (radio /
|
||||||
|
// daily_mix) and the taste-profile build settings (engagement
|
||||||
|
// half-life + completion curve). It follows the coverart
|
||||||
|
// SettingsService pattern — boot reconcile seeds shipped defaults for
|
||||||
|
// missing rows, values are cached under a RWMutex, and every change
|
||||||
|
// takes effect live (the daily_mix profile + taste config are pushed
|
||||||
|
// into package playlists; radio reads the cache per request).
|
||||||
|
//
|
||||||
|
// Framing (decision #1247): this is the defaults-discovery lab. The
|
||||||
|
// operator turns knobs to FIND good values; found-good values get
|
||||||
|
// baked back into the Shipped* functions below as new shipped
|
||||||
|
// defaults. End users and other operators should never need the card.
|
||||||
|
package recsettings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Scope names — the three tunable groups. radio + daily_mix are weight
|
||||||
|
// profiles; taste is the profile-build settings singleton.
|
||||||
|
const (
|
||||||
|
ScopeRadio = "radio"
|
||||||
|
ScopeDailyMix = "daily_mix"
|
||||||
|
ScopeTaste = "taste"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TasteTuning is the tunable subset of taste.Config: the engagement
|
||||||
|
// half-life and the completion→engagement curve points.
|
||||||
|
type TasteTuning struct {
|
||||||
|
HalfLifeDays float64
|
||||||
|
EngagementHardSkip float64
|
||||||
|
EngagementNeutral float64
|
||||||
|
EngagementFull float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
||||||
|
// here from config.RecommendationConfig — YAML is bootstrap-only,
|
||||||
|
// rule: config in UI). Radio is seed-directed (the user picked a
|
||||||
|
// direction), so taste is a lighter nudge than in the daily mixes.
|
||||||
|
func ShippedRadioWeights() recommendation.ScoringWeights {
|
||||||
|
return recommendation.ScoringWeights{
|
||||||
|
BaseWeight: 1.0,
|
||||||
|
LikeBoost: 2.0,
|
||||||
|
RecencyWeight: 1.0,
|
||||||
|
SkipPenalty: 1.0,
|
||||||
|
JitterMagnitude: 0.1,
|
||||||
|
ContextWeight: 2.0,
|
||||||
|
SimilarityWeight: 2.0,
|
||||||
|
TasteWeight: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShippedDailyMixWeights are the shipped daily_mix-profile defaults.
|
||||||
|
// Must stay in sync with the pre-push literal in playlists/system.go.
|
||||||
|
func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
||||||
|
return recommendation.ScoringWeights{
|
||||||
|
BaseWeight: 1.0,
|
||||||
|
LikeBoost: 2.0,
|
||||||
|
RecencyWeight: 1.0,
|
||||||
|
SkipPenalty: 2.0,
|
||||||
|
JitterMagnitude: 0.1,
|
||||||
|
ContextWeight: 0.5,
|
||||||
|
SimilarityWeight: 1.5,
|
||||||
|
TasteWeight: 1.5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
|
||||||
|
func ShippedTasteTuning() TasteTuning {
|
||||||
|
d := taste.DefaultConfig()
|
||||||
|
return TasteTuning{
|
||||||
|
HalfLifeDays: d.HalfLifeDays,
|
||||||
|
EngagementHardSkip: d.Engagement.HardSkip,
|
||||||
|
EngagementNeutral: d.Engagement.NeutralCompletion,
|
||||||
|
EngagementFull: d.Engagement.FullCompletion,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service caches the tuning values and owns their DB persistence +
|
||||||
|
// audit trail. Construct with New at boot.
|
||||||
|
type Service struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
logger *slog.Logger
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
profiles map[string]recommendation.ScoringWeights
|
||||||
|
taste TasteTuning
|
||||||
|
}
|
||||||
|
|
||||||
|
// New boots the service: seeds shipped defaults for missing rows,
|
||||||
|
// loads the current values, and pushes the daily_mix weights + taste
|
||||||
|
// config into package playlists so the daily builds pick them up.
|
||||||
|
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
|
||||||
|
s := &Service{
|
||||||
|
pool: pool,
|
||||||
|
logger: logger,
|
||||||
|
profiles: map[string]recommendation.ScoringWeights{},
|
||||||
|
}
|
||||||
|
if err := s.reconcile(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("recsettings boot: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcile seeds missing rows with shipped defaults, reads everything
|
||||||
|
// back into the cache, and pushes the playlist-side values.
|
||||||
|
func (s *Service) reconcile(ctx context.Context) error {
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
for profile, w := range map[string]recommendation.ScoringWeights{
|
||||||
|
ScopeRadio: ShippedRadioWeights(),
|
||||||
|
ScopeDailyMix: ShippedDailyMixWeights(),
|
||||||
|
} {
|
||||||
|
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
|
||||||
|
return fmt.Errorf("seed profile %q: %w", profile, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st := ShippedTasteTuning()
|
||||||
|
if err := q.UpsertTasteTuningDefaults(ctx, dbq.UpsertTasteTuningDefaultsParams{
|
||||||
|
HalfLifeDays: st.HalfLifeDays,
|
||||||
|
EngagementHardSkip: st.EngagementHardSkip,
|
||||||
|
EngagementNeutral: st.EngagementNeutral,
|
||||||
|
EngagementFull: st.EngagementFull,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("seed taste tuning: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := q.ListWeightProfiles(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list weight profiles: %w", err)
|
||||||
|
}
|
||||||
|
tt, err := q.GetTasteTuning(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get taste tuning: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.profiles = map[string]recommendation.ScoringWeights{}
|
||||||
|
for _, r := range rows {
|
||||||
|
s.profiles[r.Profile] = weightsFromRow(r)
|
||||||
|
}
|
||||||
|
s.taste = TasteTuning{
|
||||||
|
HalfLifeDays: tt.HalfLifeDays,
|
||||||
|
EngagementHardSkip: tt.EngagementHardSkip,
|
||||||
|
EngagementNeutral: tt.EngagementNeutral,
|
||||||
|
EngagementFull: tt.EngagementFull,
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
s.push()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// push installs the daily-build values into package playlists (the
|
||||||
|
// coverart Configure() pattern). Radio needs no push — its handler
|
||||||
|
// reads Weights(ScopeRadio) per request.
|
||||||
|
func (s *Service) push() {
|
||||||
|
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
|
||||||
|
playlists.SetTasteConfig(s.TasteConfig())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weights returns the cached weights for a profile scope. Unknown
|
||||||
|
// scopes return the shipped radio defaults (defensive; callers pass
|
||||||
|
// the Scope* constants).
|
||||||
|
func (s *Service) Weights(profile string) recommendation.ScoringWeights {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
if w, ok := s.profiles[profile]; ok {
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
return ShippedRadioWeights()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taste returns the cached taste-tuning values.
|
||||||
|
func (s *Service) Taste() TasteTuning {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return s.taste
|
||||||
|
}
|
||||||
|
|
||||||
|
// TasteConfig assembles the full taste.Config the profile builder
|
||||||
|
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
|
||||||
|
// plus the tuned half-life and curve. WindowDays scales with the
|
||||||
|
// half-life at the shipped ratio (270/75 = 3.6 half-lives) — the
|
||||||
|
// window is a query-cost bound, not an independent knob.
|
||||||
|
func (s *Service) TasteConfig() taste.Config {
|
||||||
|
t := s.Taste()
|
||||||
|
cfg := taste.DefaultConfig()
|
||||||
|
cfg.HalfLifeDays = t.HalfLifeDays
|
||||||
|
cfg.WindowDays = t.HalfLifeDays * 3.6
|
||||||
|
cfg.Engagement = taste.EngagementParams{
|
||||||
|
HardSkip: t.EngagementHardSkip,
|
||||||
|
NeutralCompletion: t.EngagementNeutral,
|
||||||
|
FullCompletion: t.EngagementFull,
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldChange is one entry of an audit row's changes array.
|
||||||
|
type fieldChange struct {
|
||||||
|
Field string `json:"field"`
|
||||||
|
Old float64 `json:"old"`
|
||||||
|
New float64 `json:"new"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateProfile applies a partial update to one weight profile.
|
||||||
|
// Unknown fields and out-of-range values reject the whole patch. A
|
||||||
|
// no-op patch (all values equal to current) writes no audit row.
|
||||||
|
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
|
||||||
|
if profile != ScopeRadio && profile != ScopeDailyMix {
|
||||||
|
return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
|
||||||
|
}
|
||||||
|
current := s.Weights(profile)
|
||||||
|
next, changes, err := applyWeightPatch(current, patch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.persistProfile(ctx, profile, next, "update", changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTaste applies a partial update to the taste tuning singleton.
|
||||||
|
func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) error {
|
||||||
|
current := s.Taste()
|
||||||
|
next, changes, err := applyTastePatch(current, patch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.persistTaste(ctx, next, "update", changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset restores a scope to its shipped defaults, with one audit row
|
||||||
|
// carrying the full diff. A scope already at defaults is a no-op.
|
||||||
|
func (s *Service) Reset(ctx context.Context, scope string) error {
|
||||||
|
switch scope {
|
||||||
|
case ScopeRadio, ScopeDailyMix:
|
||||||
|
shipped := ShippedRadioWeights()
|
||||||
|
if scope == ScopeDailyMix {
|
||||||
|
shipped = ShippedDailyMixWeights()
|
||||||
|
}
|
||||||
|
changes := diffWeights(s.Weights(scope), shipped)
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.persistProfile(ctx, scope, shipped, "reset", changes)
|
||||||
|
case ScopeTaste:
|
||||||
|
shipped := ShippedTasteTuning()
|
||||||
|
changes := diffTaste(s.Taste(), shipped)
|
||||||
|
if len(changes) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.persistTaste(ctx, shipped, "reset", changes)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistProfile writes the profile row + audit entry, refreshes the
|
||||||
|
// cache, and pushes daily-build values.
|
||||||
|
func (s *Service) persistProfile(
|
||||||
|
ctx context.Context, profile string, w recommendation.ScoringWeights,
|
||||||
|
action string, changes []fieldChange,
|
||||||
|
) error {
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
if _, err := q.UpdateWeightProfile(ctx, updateParams(profile, w)); err != nil {
|
||||||
|
return fmt.Errorf("update profile %q: %w", profile, err)
|
||||||
|
}
|
||||||
|
if err := s.audit(ctx, q, profile, action, changes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.profiles[profile] = w
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.push()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) persistTaste(
|
||||||
|
ctx context.Context, t TasteTuning, action string, changes []fieldChange,
|
||||||
|
) error {
|
||||||
|
q := dbq.New(s.pool)
|
||||||
|
if _, err := q.UpdateTasteTuning(ctx, dbq.UpdateTasteTuningParams{
|
||||||
|
HalfLifeDays: t.HalfLifeDays,
|
||||||
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
|
EngagementFull: t.EngagementFull,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("update taste tuning: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.audit(ctx, q, ScopeTaste, action, changes); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.taste = t
|
||||||
|
s.mu.Unlock()
|
||||||
|
s.push()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// audit writes one recommendation_tuning_audit row. Changes are
|
||||||
|
// sorted by field so rows are deterministic and diff-friendly.
|
||||||
|
func (s *Service) audit(
|
||||||
|
ctx context.Context, q *dbq.Queries, scope, action string, changes []fieldChange,
|
||||||
|
) error {
|
||||||
|
sort.Slice(changes, func(i, j int) bool { return changes[i].Field < changes[j].Field })
|
||||||
|
payload, err := json.Marshal(changes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal audit changes: %w", err)
|
||||||
|
}
|
||||||
|
if err := q.InsertTuningAudit(ctx, dbq.InsertTuningAuditParams{
|
||||||
|
Scope: scope, Action: action, Changes: payload,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("insert audit row: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
|
||||||
|
return dbq.UpsertWeightProfileDefaultsParams{
|
||||||
|
Profile: profile,
|
||||||
|
BaseWeight: w.BaseWeight,
|
||||||
|
LikeBoost: w.LikeBoost,
|
||||||
|
RecencyWeight: w.RecencyWeight,
|
||||||
|
SkipPenalty: w.SkipPenalty,
|
||||||
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
|
ContextWeight: w.ContextWeight,
|
||||||
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
|
TasteWeight: w.TasteWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
|
||||||
|
return dbq.UpdateWeightProfileParams{
|
||||||
|
Profile: profile,
|
||||||
|
BaseWeight: w.BaseWeight,
|
||||||
|
LikeBoost: w.LikeBoost,
|
||||||
|
RecencyWeight: w.RecencyWeight,
|
||||||
|
SkipPenalty: w.SkipPenalty,
|
||||||
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
|
ContextWeight: w.ContextWeight,
|
||||||
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
|
TasteWeight: w.TasteWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
|
||||||
|
return recommendation.ScoringWeights{
|
||||||
|
BaseWeight: r.BaseWeight,
|
||||||
|
LikeBoost: r.LikeBoost,
|
||||||
|
RecencyWeight: r.RecencyWeight,
|
||||||
|
SkipPenalty: r.SkipPenalty,
|
||||||
|
JitterMagnitude: r.JitterMagnitude,
|
||||||
|
ContextWeight: r.ContextWeight,
|
||||||
|
SimilarityWeight: r.SimilarityWeight,
|
||||||
|
TasteWeight: r.TasteWeight,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package recsettings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
if err := db.Migrate(dsn, logger); 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 newService(t *testing.T, pool *pgxpool.Pool) *Service {
|
||||||
|
t.Helper()
|
||||||
|
s, err := New(context.Background(), pool, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recsettings.New: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func auditRows(t *testing.T, pool *pgxpool.Pool) []struct {
|
||||||
|
Scope, Action string
|
||||||
|
Changes []fieldChange
|
||||||
|
} {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := pool.Query(context.Background(),
|
||||||
|
`SELECT scope, action, changes FROM recommendation_tuning_audit ORDER BY id`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query audit: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []struct {
|
||||||
|
Scope, Action string
|
||||||
|
Changes []fieldChange
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var scope, action string
|
||||||
|
var raw []byte
|
||||||
|
if err := rows.Scan(&scope, &action, &raw); err != nil {
|
||||||
|
t.Fatalf("scan audit: %v", err)
|
||||||
|
}
|
||||||
|
var changes []fieldChange
|
||||||
|
if err := json.Unmarshal(raw, &changes); err != nil {
|
||||||
|
t.Fatalf("unmarshal audit changes: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, struct {
|
||||||
|
Scope, Action string
|
||||||
|
Changes []fieldChange
|
||||||
|
}{scope, action, changes})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_SeedsShippedDefaults(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
if got := s.Weights(ScopeRadio); got != ShippedRadioWeights() {
|
||||||
|
t.Errorf("radio weights = %+v, want shipped defaults", got)
|
||||||
|
}
|
||||||
|
if got := s.Weights(ScopeDailyMix); got != ShippedDailyMixWeights() {
|
||||||
|
t.Errorf("daily_mix weights = %+v, want shipped defaults", got)
|
||||||
|
}
|
||||||
|
if got := s.Taste(); got != ShippedTasteTuning() {
|
||||||
|
t.Errorf("taste tuning = %+v, want shipped defaults", got)
|
||||||
|
}
|
||||||
|
// Seeding must not write audit rows — nothing changed.
|
||||||
|
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||||
|
t.Errorf("boot reconcile wrote %d audit rows, want 0", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateProfile_PersistsAndAudits(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
if err := s.UpdateProfile(context.Background(), ScopeRadio,
|
||||||
|
map[string]float64{"taste_weight": 2.5, "skip_penalty": 3.0}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
|
}
|
||||||
|
got := s.Weights(ScopeRadio)
|
||||||
|
if got.TasteWeight != 2.5 || got.SkipPenalty != 3.0 {
|
||||||
|
t.Errorf("weights = %+v, want taste 2.5 / skip 3.0", got)
|
||||||
|
}
|
||||||
|
// The other profile is untouched.
|
||||||
|
if s.Weights(ScopeDailyMix) != ShippedDailyMixWeights() {
|
||||||
|
t.Error("daily_mix must be unaffected by a radio update")
|
||||||
|
}
|
||||||
|
// Values survive a fresh boot (persisted, not just cached), and the
|
||||||
|
// reconcile's ON CONFLICT DO NOTHING doesn't clobber tuned rows.
|
||||||
|
s2 := newService(t, pool)
|
||||||
|
if got := s2.Weights(ScopeRadio); got.TasteWeight != 2.5 {
|
||||||
|
t.Errorf("rebooted taste_weight = %v, want 2.5", got.TasteWeight)
|
||||||
|
}
|
||||||
|
rows := auditRows(t, pool)
|
||||||
|
if len(rows) != 1 {
|
||||||
|
t.Fatalf("audit rows = %d, want 1", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].Scope != ScopeRadio || rows[0].Action != "update" || len(rows[0].Changes) != 2 {
|
||||||
|
t.Errorf("audit row = %+v, want radio/update with 2 changes", rows[0])
|
||||||
|
}
|
||||||
|
// Changes are field-sorted: skip_penalty before taste_weight.
|
||||||
|
if rows[0].Changes[0].Field != "skip_penalty" || rows[0].Changes[0].New != 3.0 {
|
||||||
|
t.Errorf("changes[0] = %+v, want skip_penalty → 3.0", rows[0].Changes[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateProfile_Validation(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
scope string
|
||||||
|
patch map[string]float64
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"unknown scope", "banana", map[string]float64{"taste_weight": 1}, ErrUnknownScope},
|
||||||
|
{"unknown field", ScopeRadio, map[string]float64{"vibes": 1}, ErrUnknownField},
|
||||||
|
{"over bound", ScopeRadio, map[string]float64{"taste_weight": 11}, ErrOutOfRange},
|
||||||
|
{"negative jitter", ScopeRadio, map[string]float64{"jitter_magnitude": -0.1}, ErrOutOfRange},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if err := s.UpdateProfile(context.Background(), c.scope, c.patch); !errors.Is(err, c.want) {
|
||||||
|
t.Errorf("%s: err = %v, want %v", c.name, err, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s.Weights(ScopeRadio) != ShippedRadioWeights() {
|
||||||
|
t.Error("rejected patches must not partially apply")
|
||||||
|
}
|
||||||
|
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||||
|
t.Errorf("rejected patches wrote %d audit rows, want 0", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTaste_CurveOrderingEnforced(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
// Moving neutral above full must reject on the PATCHED result.
|
||||||
|
err := s.UpdateTaste(context.Background(), map[string]float64{"engagement_neutral": 0.95})
|
||||||
|
if !errors.Is(err, ErrOutOfRange) {
|
||||||
|
t.Fatalf("err = %v, want ErrOutOfRange (curve ordering)", err)
|
||||||
|
}
|
||||||
|
// A coherent multi-point move is fine.
|
||||||
|
if err := s.UpdateTaste(context.Background(), map[string]float64{
|
||||||
|
"engagement_neutral": 0.40, "engagement_full": 0.95, "half_life_days": 30,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("UpdateTaste: %v", err)
|
||||||
|
}
|
||||||
|
cfg := s.TasteConfig()
|
||||||
|
if cfg.HalfLifeDays != 30 || cfg.Engagement.NeutralCompletion != 0.40 {
|
||||||
|
t.Errorf("taste config = %+v, want tuned values", cfg)
|
||||||
|
}
|
||||||
|
// WindowDays scales with the half-life at the shipped ratio.
|
||||||
|
if cfg.WindowDays != 30*3.6 {
|
||||||
|
t.Errorf("WindowDays = %v, want %v", cfg.WindowDays, 30*3.6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReset_RestoresShippedAndAudits(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
// Reset with nothing changed is a no-op (no audit row).
|
||||||
|
if err := s.Reset(context.Background(), ScopeDailyMix); err != nil {
|
||||||
|
t.Fatalf("no-op reset: %v", err)
|
||||||
|
}
|
||||||
|
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||||
|
t.Fatalf("no-op reset wrote audit rows")
|
||||||
|
}
|
||||||
|
if err := s.UpdateProfile(context.Background(), ScopeDailyMix,
|
||||||
|
map[string]float64{"similarity_weight": 4}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.Reset(context.Background(), ScopeDailyMix); err != nil {
|
||||||
|
t.Fatalf("Reset: %v", err)
|
||||||
|
}
|
||||||
|
if s.Weights(ScopeDailyMix) != ShippedDailyMixWeights() {
|
||||||
|
t.Error("reset must restore shipped defaults")
|
||||||
|
}
|
||||||
|
rows := auditRows(t, pool)
|
||||||
|
if len(rows) != 2 || rows[1].Action != "reset" {
|
||||||
|
t.Fatalf("audit rows = %+v, want update then reset", rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdate_NoOpWritesNoAudit(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
s := newService(t, pool)
|
||||||
|
shipped := ShippedRadioWeights()
|
||||||
|
if err := s.UpdateProfile(context.Background(), ScopeRadio,
|
||||||
|
map[string]float64{"taste_weight": shipped.TasteWeight}); err != nil {
|
||||||
|
t.Fatalf("UpdateProfile: %v", err)
|
||||||
|
}
|
||||||
|
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||||
|
t.Errorf("no-op update wrote %d audit rows, want 0", len(rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,9 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mailer"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/web"
|
"git.fabledsword.com/bvandeusen/minstrel/web"
|
||||||
)
|
)
|
||||||
@@ -76,6 +78,7 @@ type Server struct {
|
|||||||
BrandingCfg config.BrandingConfig
|
BrandingCfg config.BrandingConfig
|
||||||
CoverEnricher *coverart.Enricher
|
CoverEnricher *coverart.Enricher
|
||||||
CoverSettings *coverart.SettingsService
|
CoverSettings *coverart.SettingsService
|
||||||
|
TagSettings *tags.SettingsService
|
||||||
LibraryScanner *library.Scanner
|
LibraryScanner *library.Scanner
|
||||||
ScanCfg library.RunScanConfig
|
ScanCfg library.RunScanConfig
|
||||||
// Bus is the live-event bus shared with background workers (the
|
// Bus is the live-event bus shared with background workers (the
|
||||||
@@ -88,6 +91,13 @@ type Server struct {
|
|||||||
// PUT /api/me/timezone and POST /api/auth/register can call
|
// PUT /api/me/timezone and POST /api/auth/register can call
|
||||||
// Refresh synchronously.
|
// Refresh synchronously.
|
||||||
PlaylistScheduler *playlists.Scheduler
|
PlaylistScheduler *playlists.Scheduler
|
||||||
|
// RecSettings is the DB-backed recommendation tuning lab (#1250):
|
||||||
|
// scoring-weight profiles + taste-build knobs. Constructed in
|
||||||
|
// cmd/minstrel/main.go (it pushes daily-mix weights into package
|
||||||
|
// playlists at boot); the API layer reads radio weights per request
|
||||||
|
// and serves the admin tuning endpoints from it. Router() constructs
|
||||||
|
// a fallback when nil (tests).
|
||||||
|
RecSettings *recsettings.Service
|
||||||
// StreamSecret is the HMAC key used by /api/cast/stream-token to
|
// StreamSecret is the HMAC key used by /api/cast/stream-token to
|
||||||
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
|
// mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream
|
||||||
// to verify them. Sourced from config.Config.StreamSecret. Tests that
|
// to verify them. Sourced from config.Config.StreamSecret. Tests that
|
||||||
@@ -143,7 +153,18 @@ func (s *Server) Router() http.Handler {
|
|||||||
if bus == nil {
|
if bus == nil {
|
||||||
bus = eventbus.New()
|
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.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
|
recSettings := s.RecSettings
|
||||||
|
if recSettings == nil {
|
||||||
|
// Test contexts construct Server directly without main.go's
|
||||||
|
// boot wiring; reconcile here so radio + the admin tuning
|
||||||
|
// endpoints work against the same pool.
|
||||||
|
var err error
|
||||||
|
recSettings, err = recsettings.New(context.Background(), s.Pool, s.Logger)
|
||||||
|
if err != nil {
|
||||||
|
s.Logger.Error("server: recsettings boot failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.TagSettings, 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
|
// /api/admin/scan is the only admin route owned by the server package
|
||||||
// (it needs the Scanner). Register it as a single inline-middleware
|
// (it needs the Scanner). Register it as a single inline-middleware
|
||||||
// route — using r.Route("/api/admin", ...) here would create a second
|
// route — using r.Route("/api/admin", ...) here would create a second
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user