Merge pull request 'Taste-profile fidelity (M160) + Songs-like row + home polish' (#109) from dev into main
test-go / test (push) Successful in 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Successful in 5m1s
android / Build + lint + test (push) Successful in 5m5s
release / Build signed APK (tag releases only) (push) Successful in 4m9s
release / Build + push container image (push) Successful in 19s

This commit was merged in pull request #109.
This commit is contained in:
2026-07-14 13:03:12 -04:00
110 changed files with 6532 additions and 473 deletions
@@ -0,0 +1,48 @@
package com.fabledsword.minstrel.admin.data
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton
/**
* Read-through accessor for the tag-enrichment provider settings (#1521).
* No Room caching — admin settings are infrequent point-and-shoot edits;
* mutations fire direct REST and the ViewModel reconciles on failure.
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
*/
@Singleton
class AdminTagSourcesRepository @Inject constructor(
retrofit: Retrofit,
) {
private val api: AdminTagSourcesApi = retrofit.create()
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
}
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
id = id,
displayName = displayName,
requiresApiKey = requiresApiKey,
supports = supports,
enabled = enabled,
apiKeySet = apiKeySet,
testable = testable,
)
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
@@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.composables.icons.lucide.Inbox
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.TriangleAlert
import com.composables.icons.lucide.Users
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.nav.Admin
import com.fabledsword.minstrel.nav.AdminQuarantine
import com.fabledsword.minstrel.nav.AdminRequests
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.nav.AdminUsers
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
@@ -54,7 +57,7 @@ import javax.inject.Inject
// ─── State ───────────────────────────────────────────────────────────
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
sealed interface AdminLandingUiState {
data object Loading : AdminLandingUiState
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
private val requestsRepo: AdminRequestsRepository,
private val quarantineRepo: AdminQuarantineRepository,
private val usersRepo: AdminUsersRepository,
private val tagSourcesRepo: AdminTagSourcesRepository,
) : ViewModel() {
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
val req = async { requestsRepo.list().size }
val qua = async { quarantineRepo.list().size }
val usr = async { usersRepo.list().size }
AdminCounts(req.await(), qua.await(), usr.await())
val tag = async { tagSourcesRepo.list().count { it.enabled } }
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
}
internal.value = AdminLandingUiState.Success(counts)
} catch (
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
onClick = { navController.navigate(AdminUsers) },
)
}
item {
SectionCard(
icon = Lucide.Music,
title = "Tag sources",
subtitle = "Metadata enrichment providers",
count = counts.tagSources,
onClick = { navController.navigate(AdminTagSources) },
)
}
}
}
@@ -0,0 +1,220 @@
package com.fabledsword.minstrel.admin.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import com.fabledsword.minstrel.nav.AdminTagSources
import com.fabledsword.minstrel.shared.widgets.EmptyState
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdminTagSourcesScreen(
navController: NavHostController,
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
MinstrelTopAppBar(
title = "Admin · Tag sources",
navController = navController,
currentRouteName = AdminTagSources::class.qualifiedName,
onBack = { navController.popBackStack() },
)
},
) { inner ->
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
when (val s = state) {
AdminTagSourcesUiState.Loading -> LoadingCentered()
AdminTagSourcesUiState.Empty -> EmptyState(
title = "No tag sources",
body = "Tag-enrichment providers register on the server; none are available.",
)
is AdminTagSourcesUiState.Error -> ErrorRetry(
title = "Couldn't load tag sources",
message = s.message,
onRetry = { viewModel.refresh() },
)
is AdminTagSourcesUiState.Success -> TagSourceList(
providers = s.providers,
testResults = s.testResults,
onToggle = viewModel::setEnabled,
onSaveKey = viewModel::saveApiKey,
onTest = viewModel::test,
)
}
}
}
}
@Composable
private fun TagSourceList(
providers: List<AdminTagSourceRef>,
testResults: Map<String, TagSourceTestResult>,
onToggle: (String, Boolean) -> Unit,
onSaveKey: (String, String) -> Unit,
onTest: (String) -> Unit,
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(items = providers, key = { it.id }) { provider ->
TagSourceCard(
provider = provider,
testResult = testResults[provider.id],
onToggle = onToggle,
onSaveKey = onSaveKey,
onTest = onTest,
)
}
}
}
@Composable
private fun TagSourceCard(
provider: AdminTagSourceRef,
testResult: TagSourceTestResult?,
onToggle: (String, Boolean) -> Unit,
onSaveKey: (String, String) -> Unit,
onTest: (String) -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column(modifier = Modifier.weight(1f)) {
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
Text(
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Switch(
checked = provider.enabled,
onCheckedChange = { onToggle(provider.id, it) },
)
}
if (provider.requiresApiKey) {
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
}
if (provider.testable) {
TestRow(provider = provider, testResult = testResult, onTest = onTest)
}
}
}
}
@Composable
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
var key by remember(provider.id) { mutableStateOf("") }
OutlinedTextField(
value = key,
onValueChange = { key = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("API key") },
placeholder = {
Text(
if (provider.apiKeySet) {
"••• saved — leave blank to keep"
} else {
"Paste your API key to enable this source"
},
)
},
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = {
onSaveKey(provider.id, key)
key = ""
},
enabled = key.isNotBlank(),
) { Text("Save key") }
if (provider.apiKeySet) {
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun TestRow(
provider: AdminTagSourceRef,
testResult: TagSourceTestResult?,
onTest: (String) -> Unit,
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
testResult?.let { result ->
if (result.ok) {
Text(
text = "OK (${result.durationMs}ms)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Text(
text = "Failed — ${result.error}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
}
}
}
@@ -0,0 +1,116 @@
package com.fabledsword.minstrel.admin.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.recoveries
import com.fabledsword.minstrel.models.AdminTagSourceRef
import com.fabledsword.minstrel.models.TagSourceTestResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface AdminTagSourcesUiState {
data object Loading : AdminTagSourcesUiState
data object Empty : AdminTagSourcesUiState
data class Success(
val providers: List<AdminTagSourceRef>,
val testResults: Map<String, TagSourceTestResult>,
) : AdminTagSourcesUiState
data class Error(val message: String) : AdminTagSourcesUiState
}
@HiltViewModel
class AdminTagSourcesViewModel @Inject constructor(
private val repository: AdminTagSourcesRepository,
networkStatus: NetworkStatusController,
) : ViewModel() {
private val internal = MutableStateFlow<AdminTagSourcesUiState>(AdminTagSourcesUiState.Loading)
val uiState: StateFlow<AdminTagSourcesUiState> = internal.asStateFlow()
init {
refresh()
// Screen-level auto-recovery: reload a failed list when server
// health returns instead of waiting for a manual pull (issue #1245).
viewModelScope.launch {
networkStatus.recoveries().collect {
if (internal.value is AdminTagSourcesUiState.Error) refresh()
}
}
}
fun refresh(): Job = viewModelScope.launch {
internal.value = AdminTagSourcesUiState.Loading
try {
val providers = repository.list()
internal.value = if (providers.isEmpty()) {
AdminTagSourcesUiState.Empty
} else {
AdminTagSourcesUiState.Success(providers, emptyMap())
}
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e))
}
}
fun setEnabled(id: String, enabled: Boolean) {
val before = internal.value as? AdminTagSourcesUiState.Success ?: return
// Optimistic toggle; reconcile via refresh() if the server rejects.
internal.value = before.copy(
providers = before.providers.map {
if (it.id == id) it.copy(enabled = enabled) else it
},
)
viewModelScope.launch {
try {
repository.setEnabled(id, enabled)
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
fun saveApiKey(id: String, apiKey: String) {
viewModelScope.launch {
try {
replaceProvider(repository.setApiKey(id, apiKey))
} catch (
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
) {
refresh()
}
}
}
fun test(id: String) {
viewModelScope.launch {
val result = try {
repository.test(id)
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e))
}
val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch
internal.value = current.copy(testResults = current.testResults + (id to result))
}
}
private fun replaceProvider(updated: AdminTagSourceRef) {
val current = internal.value as? AdminTagSourcesUiState.Success ?: return
internal.value = current.copy(
providers = current.providers.map { if (it.id == updated.id) updated else it },
)
}
}
@@ -0,0 +1,41 @@
package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
import com.fabledsword.minstrel.models.wire.AdminTagSourcesListWire
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
/**
* Retrofit interface for `/api/admin/tag-sources` (#1521) — the admin
* surface for the tag-enrichment provider settings (#1490). Mirrors the
* web integrations "Tag enrichment sources" card. Same shape as the
* cover-sources admin surface; kept independent so a new tag source is
* added without touching art settings.
*/
interface AdminTagSourcesApi {
@GET("api/admin/tag-sources")
suspend fun list(): AdminTagSourcesListWire
@PATCH("api/admin/tag-sources/{id}")
suspend fun update(@Path("id") id: String, @Body body: UpdateTagSourceBody): AdminTagSourceWire
@POST("api/admin/tag-sources/{id}/test")
suspend fun test(@Path("id") id: String): TestTagSourceWire
}
/**
* PATCH body. Both fields are nullable + default-null so kotlinx omits the
* untouched one (the server reads a missing/null field as "leave
* unchanged"): send only `enabled` to toggle, only `apiKey` to set a key.
*/
@Serializable
data class UpdateTagSourceBody(
val enabled: Boolean? = null,
@SerialName("api_key") val apiKey: String? = null,
)
@@ -239,6 +239,9 @@ data class PlayOfflinePayload(
val atIso: String,
val durationPlayedMs: Long,
val source: String? = null,
// #1551: device class for context conditioning; null on payloads queued
// before this field existed (decodes to null → server stores NULL).
val deviceClass: String? = null,
)
/**
@@ -254,6 +254,7 @@ class MutationReplayer @Inject constructor(
at = decoded.atIso,
durationPlayedMs = decoded.durationPlayedMs,
source = decoded.source,
deviceClass = decoded.deviceClass,
),
)
return Outcome.SENT
@@ -2,11 +2,19 @@
package com.fabledsword.minstrel.home.ui
import androidx.compose.animation.AnimatedVisibility
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.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -88,6 +97,7 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -110,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_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 ───────────────────────────────────────────────────────────
data class HomeSections(
@@ -177,22 +197,36 @@ class HomeViewModel @Inject constructor(
*/
private val refreshError = MutableStateFlow<String?>(null)
private val updatingInternal = MutableStateFlow(false)
/**
* True while an automatic background refresh (the 03:00 daily rebuild
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
* your mixes…" veil so the section churn — delete-then-insert in
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
* hidden behind the veil instead of on screen. Manual pull-to-refresh
* and cold start are NOT veiled (they own the pull spinner / skeleton).
*/
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
init {
refresh()
// Screen-level auto-recovery (issue #1245): a Home that failed to
// load while the server was unreachable re-pulls itself the moment
// health returns — same idiom as SyncController, one layer up.
// Veiled: content is already on screen and would otherwise churn.
viewModelScope.launch {
networkStatus.recoveries().collect { refresh() }
networkStatus.recoveries().collect { refreshBehindVeil() }
}
// #968: the daily 03:00 rebuild (and manual refresh) emit
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
// and You-might-like rows reflect the new snapshot without a manual
// reload. Mirrors the web SSE consumer.
// reload. Mirrors the web SSE consumer. Veiled so the multi-section
// rebuild churn hides behind "Updating your mixes…".
viewModelScope.launch {
eventsStream.events
.filter { it.kind == "playlist.system_rebuilt" }
.collect { refresh() }
.collect { refreshBehindVeil() }
}
}
@@ -323,6 +357,26 @@ class HomeViewModel @Inject constructor(
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>> =
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
@@ -398,47 +452,108 @@ fun HomeScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
PullToRefreshScaffold(
onRefresh = { viewModel.refresh().join() },
modifier = Modifier.fillMaxSize().padding(inner),
) {
// Key Crossfade on the state CLASS, not the instance. Each
// section emission produces a new UiState.Success(data); if
// we keyed on `state` directly, every per-section
// hydration tick would re-run the 300ms crossfade, and
// first-sign-in (six sections cascading in) reads as
// continuous flicker. Keying on the class restricts the
// animation to Loading↔Success↔Empty↔Error transitions and
// lets normal Success→Success recompositions update the
// LazyColumn without a fade.
Crossfade(targetState = state::class, label = "home-state") { _ ->
when (val s = state) {
UiState.Loading -> HomeSkeletonContent()
UiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is UiState.Error -> ErrorRetry(
title = "Couldn't load home",
message = s.message,
onRetry = { viewModel.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,
)
}
Box(Modifier.fillMaxSize()) {
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
// Automatic-refresh veil: the daily rebuild / reconnect
// churn hides behind an "Updating your mixes…" wipe. Manual
// pull owns the PullToRefreshBox spinner instead.
UpdatingVeil(visible = updating)
}
}
}
}
/**
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
*
* Key the Crossfade on the state CLASS, not the instance. Each section
* emission produces a new UiState.Success(data); if we keyed on `state`
* directly, every per-section hydration tick would re-run the 300ms
* crossfade, and first-sign-in (six sections cascading in) reads as
* continuous flicker. Keying on the class restricts the animation to
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
* recompositions update the LazyColumn without a fade.
*/
@Composable
private fun HomeStateCrossfade(
state: UiState<HomeSections>,
systemStatus: SystemPlaylistsStatus,
offline: Boolean,
navController: NavHostController,
viewModel: HomeViewModel,
) {
Crossfade(targetState = state::class, label = "home-state") { _ ->
when (val s = state) {
UiState.Loading -> HomeSkeletonContent()
UiState.Empty -> EmptyState(
title = "Welcome to Minstrel",
body = "Nothing to show yet — scan a folder in your server " +
"settings, then come back here for system playlists " +
"and recommendations.",
)
is UiState.Error -> ErrorRetry(
title = "Couldn't load home",
message = s.message,
onRetry = { viewModel.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,
)
}
}
}
@@ -508,10 +623,12 @@ private fun HomeSuccessContent(
) {
item {
// Always rendered: real system/user playlists, with
// placeholder cards filling the For You / Discover /
// 3× Songs-like slots that haven't generated yet. When
// offline, the cache-backed pool cards lead the row.
// placeholder cards filling the For You / Discover slots
// that haven't generated yet. When offline, the cache-backed
// pool cards lead the row. Songs-like now lives in its own
// dedicated row below (#1491), no longer inside this carousel.
PlaylistsRow(
title = "Playlists",
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
offline = offline,
onPlaylistClick = onPlaylistClick,
@@ -519,6 +636,18 @@ private fun HomeSuccessContent(
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(
albums = sections.youMightLikeAlbums,
artists = sections.youMightLikeArtists,
@@ -542,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(
albums: List<HomeTile<AlbumRef>>,
onAlbumClick: (String) -> Unit,
@@ -811,13 +969,14 @@ private fun AlbumsRow(
@Composable
private fun PlaylistsRow(
title: String,
rowItems: List<PlaylistRowItem>,
offline: Boolean,
onPlaylistClick: (String) -> Unit,
onPlayPool: (OfflinePoolKind) -> Unit,
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
) {
HorizontalScrollRow(title = "Playlists") {
HorizontalScrollRow(title = title) {
itemsIndexed(items = rowItems) { _, item ->
when (item) {
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
@@ -868,11 +1027,12 @@ enum class OfflinePoolKind(val label: String) {
/**
* Builds the Home Playlists row.
*
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
* / rediscover / new for you / on this day / first listens) when they exist —
* Online: For You + Discover fixed slots (real card when generated,
* placeholder otherwise), then the secondary system kinds (deep cuts /
* rediscover / new for you / on this day / first listens) when they exist —
* 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
* same real playlists in curated order but stably partitioned fully-cached
@@ -899,7 +1059,7 @@ internal fun buildPlaylistsRow(
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(
owned: List<PlaylistRef>,
status: SystemPlaylistsStatus,
@@ -911,12 +1071,7 @@ private fun buildOnlineRow(
out += owned.firstOrNull { it.systemVariant == "discover" }
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
for (i in 0 until SONGS_LIKE_SLOTS) {
out += songsLike.getOrNull(i)
?.let { PlaylistRowItem.Real(it) }
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
}
// Songs-like is no longer here — it has its own dedicated row (#1491).
for (variant in SECONDARY_SYSTEM_VARIANTS) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
}
@@ -924,16 +1079,44 @@ private fun buildOnlineRow(
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).
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
* 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> {
val out = mutableListOf<PlaylistRef>()
owned.firstOrNull { it.systemVariant == "for_you" }?.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) {
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
}
@@ -948,7 +1131,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
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
@@ -0,0 +1,19 @@
package com.fabledsword.minstrel.models
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
data class AdminTagSourceRef(
val id: String,
val displayName: String,
val requiresApiKey: Boolean,
val supports: List<String>,
val enabled: Boolean,
val apiKeySet: Boolean,
val testable: Boolean,
)
/** Result of a provider "test connection" call. */
data class TagSourceTestResult(
val ok: Boolean,
val durationMs: Long = 0,
val error: String = "",
)
@@ -0,0 +1,35 @@
package com.fabledsword.minstrel.models.wire
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
* Every field defaults defensively so a missing JSON key never crashes
* deserialization. `versionBumped` is present only on the PATCH response.
*/
@Serializable
data class AdminTagSourceWire(
val id: String = "",
@SerialName("display_name") val displayName: String = "",
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
val supports: List<String> = emptyList(),
val enabled: Boolean = false,
@SerialName("api_key_set") val apiKeySet: Boolean = false,
@SerialName("display_order") val displayOrder: Int = 0,
val testable: Boolean = false,
@SerialName("version_bumped") val versionBumped: Boolean = false,
)
@Serializable
data class AdminTagSourcesListWire(
val providers: List<AdminTagSourceWire> = emptyList(),
@SerialName("sources_version") val sourcesVersion: Int = 0,
)
@Serializable
data class TestTagSourceWire(
val ok: Boolean = false,
@SerialName("duration_ms") val durationMs: Long = 0,
val error: String = "",
)
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
@SerialName("track_id") val trackId: String,
@SerialName("client_id") val clientId: String,
val source: String? = null,
// #1551: device class for context conditioning (server normalizes).
@SerialName("device_class") val deviceClass: String? = null,
)
@Serializable
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
val at: String,
@SerialName("duration_played_ms") val durationPlayedMs: Long,
val source: String? = null,
// #1551: device class for context conditioning (server normalizes).
@SerialName("device_class") val deviceClass: String? = null,
)
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
import com.fabledsword.minstrel.auth.ui.LoginScreen
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
}
}
}
composable<AdminTagSources> {
WithAnimatedScope {
ShellScaffold(onExpandPlayer = expandPlayer) {
AdminTagSourcesScreen(navController = navController)
}
}
}
}
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
@Serializable data object AdminRequests
@Serializable data object AdminQuarantine
@Serializable data object AdminUsers
@Serializable data object AdminTagSources
// ── Outside-shell full-screen routes ──────────────────────────────────
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
trackId = trackId,
clientId = cid,
source = source.takeIf { !it.isNullOrEmpty() },
deviceClass = DEVICE_CLASS,
),
)
if (curTrackId == trackId) {
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
atIso = startedAt.toString(),
durationPlayedMs = durationPlayedMs,
source = source.takeIf { !it.isNullOrEmpty() },
deviceClass = DEVICE_CLASS,
),
)
}
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
// ── Lifecycle: durable-close on app background / detach ────────
companion object {
// #1551: Android is a mobile client. Refine to tablet/tv via device
// configuration later if the metrics trend view shows it matters.
private const val DEVICE_CLASS = "mobile"
}
override fun onStop(owner: LifecycleOwner) {
// App backgrounded. If the tracked play is still PLAYING, leave it
// alone: the foreground media service keeps the process — and this
@@ -23,8 +23,12 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
* ServerImage + fallback structure was identical at all three sites.
*
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
* (e.g. the `VariantPill` system-playlist label) — callers can use
* `Modifier.align(...)` inside it.
* (e.g. the `VariantPill` system-playlist label, the play button) —
* 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
fun CoverTile(
@@ -38,18 +42,23 @@ fun CoverTile(
overlay: @Composable BoxScope.() -> Unit = {},
) {
Box(
modifier = modifier
.size(size)
.clip(shape)
.background(background),
modifier = modifier.size(size),
contentAlignment = Alignment.Center,
) {
ServerImage(
url = url,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
Box(
modifier = Modifier
.fillMaxSize()
.clip(shape)
.background(background),
contentAlignment = Alignment.Center,
) {
fallback()
ServerImage(
url = url,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
) {
fallback()
}
}
overlay()
}
@@ -56,4 +56,51 @@ class BuildPlaylistsRowTest {
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
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())
}
}
+28
View File
@@ -15,6 +15,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
@@ -32,6 +33,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/similarity"
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
func main() {
@@ -205,6 +207,31 @@ func run() error {
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
go similarityWorker.Run(ctx)
// Start the household co-play worker (#1533). Recomputes artistartist
// co-occurrence edges (source='user_cooccurrence') from play_events every
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
// SQL, no external calls; empty on single-user servers.
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
// tag providers with tag_provider_settings, bumps the sources version if
// the provider set changed (re-opening settled rows), then drains tracks
// needing folksonomy tags on a periodic tick. Standalone (not in the file
// scan chain) because tag lookups need only DB fields — MBID / artist /
// title — that a scan has already imported.
tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags"))
if err != nil {
logger.Error("tag settings service init failed", "err", err)
os.Exit(1)
}
if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil {
logger.Warn("tags: provider-hash boot check failed", "err", berr)
} else if bumped {
logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer)
}
tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings)
go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx)
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
// that have no writer-side close path or retention policy:
// orphan play_events, stale play_sessions, expired
@@ -306,6 +333,7 @@ func run() error {
srv.Bus = bus
srv.PlaylistScheduler = playlistScheduler
srv.RecSettings = recSettings
srv.TagSettings = tagSettings
srv.StreamSecret = cfg.StreamSecret
httpServer := &http.Server{
Addr: cfg.Server.Address,
+24 -16
View File
@@ -20,26 +20,28 @@ import (
// weightsResp is one weight profile on the wire, keyed by the same
// snake_case field names the PATCH body accepts.
type weightsResp struct {
BaseWeight float64 `json:"base_weight"`
LikeBoost float64 `json:"like_boost"`
RecencyWeight float64 `json:"recency_weight"`
SkipPenalty float64 `json:"skip_penalty"`
JitterMagnitude float64 `json:"jitter_magnitude"`
ContextWeight float64 `json:"context_weight"`
SimilarityWeight float64 `json:"similarity_weight"`
TasteWeight float64 `json:"taste_weight"`
BaseWeight float64 `json:"base_weight"`
LikeBoost float64 `json:"like_boost"`
RecencyWeight float64 `json:"recency_weight"`
SkipPenalty float64 `json:"skip_penalty"`
JitterMagnitude float64 `json:"jitter_magnitude"`
ContextWeight float64 `json:"context_weight"`
SimilarityWeight float64 `json:"similarity_weight"`
TasteWeight float64 `json:"taste_weight"`
ContextTimeWeight float64 `json:"context_time_weight"`
}
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
return weightsResp{
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
ContextTimeWeight: w.ContextTimeWeight,
}
}
@@ -48,6 +50,9 @@ type tasteTuningResp struct {
EngagementHardSkip float64 `json:"engagement_hard_skip"`
EngagementNeutral float64 `json:"engagement_neutral"`
EngagementFull float64 `json:"engagement_full"`
EnrichedTagScale float64 `json:"enriched_tag_scale"`
EraScale float64 `json:"era_scale"`
MoodScale float64 `json:"mood_scale"`
}
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
@@ -56,6 +61,9 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
MoodScale: t.MoodScale,
}
}
+153
View File
@@ -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})
}
+243
View File
@@ -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")
}
}
+9 -1
View File
@@ -23,13 +23,14 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
"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"
)
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
// RequireUser; everything else is gated by the middleware. The events writer
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, 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()))
h := &handlers{
pool: pool, logger: logger, events: events, recCfg: recCfg,
@@ -42,6 +43,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
playlists: playlistsSvc,
coverart: coverEnricher,
coverSettings: coverSettings,
tagSettings: tagSettings,
scanner: scanner,
scanCfg: scanCfg,
dataDir: dataDir,
@@ -200,6 +202,11 @@ 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/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.Put("/smtp-config", h.handleUpdateSMTPConfig)
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
@@ -242,6 +249,7 @@ type handlers struct {
playlists *playlists.Service
coverart *coverart.Enricher
coverSettings *coverart.SettingsService
tagSettings *tags.SettingsService
scanner *library.Scanner
scanCfg library.RunScanConfig
dataDir string
+33 -2
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -24,6 +25,33 @@ type eventRequest struct {
// / "discover" feed the system-playlist rotation dedup (#415);
// absent / "" for library, user-playlist, radio, Subsonic.
Source *string `json:"source"`
// DeviceClass is the client's self-reported device class (#1551),
// normalized + stored per play to condition radio on the current device.
DeviceClass *string `json:"device_class"`
}
// knownDeviceClasses whitelists the device classes the context-affinity facet
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
// "other", and empty/absent to "" (stored NULL → excluded from the device
// dimension). Kept permissive (no DB CHECK) so a new client class is one
// whitelist entry, not a migration.
var knownDeviceClasses = map[string]bool{
"mobile": true, "tablet": true, "desktop": true,
"web": true, "tv": true, "watch": true,
}
func normalizeDeviceClass(dc *string) string {
if dc == nil {
return ""
}
s := strings.ToLower(strings.TrimSpace(*dc))
if s == "" {
return ""
}
if knownDeviceClasses[s] {
return s
}
return "other"
}
type playStartedResponse struct {
@@ -114,7 +142,9 @@ func (h *handlers) handleEventPlayStarted(
if req.Source != nil {
source = *req.Source
}
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
deviceClass := normalizeDeviceClass(req.DeviceClass)
res, err := h.events.RecordPlayStartedWithSource(
r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
if err != nil {
h.logger.Error("api: events: play_started", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err))
@@ -159,7 +189,8 @@ func (h *handlers) handleEventPlayOffline(
source = *req.Source
}
if err := h.events.RecordOfflinePlay(
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
r.Context(), user.ID, trackID, clientID, source,
normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
); err != nil {
h.logger.Error("api: events: play_offline", "err", err)
writeErr(w, apierror.InternalMsg("record failed", err))
+1 -1
View File
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
r := chi.NewRouter()
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
30*time.Minute, 0.5, 30000)
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, 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{
"/api/artists",
+11 -10
View File
@@ -171,18 +171,19 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
}
// pickKindLabels is the display vocabulary for play_events.pick_kind
// values (mirrors the CHECK in migration 0039). Every system mix that
// 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",
"cross_user": "Liked by others",
"random": "Random unheard",
"tier1": "Tier 1 (exact)",
"tier2": "Tier 2 (relaxed)",
"tier3": "Tier 3 (stretched)",
"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
@@ -192,7 +193,7 @@ var pickKindLabels = map[string]string{
// 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", "cross_user", "random",
"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
"tier1", "tier2", "tier3", "",
}
@@ -171,7 +171,7 @@ func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
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 40/30/30 allocation judgeable instead of a guess.
// 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{
+22
View File
@@ -1,6 +1,7 @@
package api
import (
"context"
"encoding/json"
"errors"
"log/slog"
@@ -81,6 +82,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
}
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
// Condition on the current device (#1551): the latest play's device is a
// cheap, request-free proxy for what the user is on right now.
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
limits := recommendation.DefaultCandidateSourceLimits()
@@ -150,6 +154,24 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
return v
}
// latestDeviceClass returns the device_class of the user's most recent play as
// the "current device" for context conditioning (#1551), or "" when unknown
// (no plays yet, or the latest play predates device capture). Best-effort: a
// lookup failure yields a device-agnostic ("") affinity cell.
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
dc, err := q.GetLatestPlayDeviceClassForUser(ctx, userID)
if err != nil {
if !errors.Is(err, pgx.ErrNoRows) {
logger.Warn("api: radio: latest device class", "err", err)
}
return ""
}
if dc == nil {
return ""
}
return *dc
}
// parseExcludeParam parses a comma-separated list of UUIDs from the
// `exclude` query string, silently dropping malformed entries. Returns
// nil for empty or all-malformed input.
+86
View File
@@ -0,0 +1,86 @@
// Package coplay builds household co-play similarity edges (#1533, milestone
// #160 Opt 5). Minstrel is a single shared-library, multi-user server (no
// per-user library ACL), so the "household" is the whole instance's user set.
// A periodic worker recomputes artistartist co-occurrence from play_events
// entirely in SQL (no external API) and stores the edges in artist_similarity
// under source='user_cooccurrence' — the pre-provisioned co-occurrence source
// from the 0009 schema — which the radio/mix candidate pool consumes as a
// collaborative arm. Single-user servers produce no edges (the >= 2 co-player
// gate), so the feature is a graceful no-op there.
package coplay
import (
"context"
"log/slog"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// minJaccard is the co-play edge floor: prune weak pairs and self-limit hub
// artists, whose large combined-audience denominators pull their Jaccard down.
// 0.1 keeps only pairs whose co-player overlap is a real fraction of their
// combined audience.
const minJaccard = 0.1
// Worker periodically rebuilds the household co-play edge set. Pure local SQL,
// so there are no rate limits — but the pair self-join is roughly
// O(users × artists_per_user²), so it ticks slowly: co-listening shifts over
// days, not minutes, and a frequent rebuild would burn CPU for no fresher
// signal.
type Worker struct {
pool *pgxpool.Pool
logger *slog.Logger
tick time.Duration
}
// NewWorker constructs a worker with the production default 6h tick.
func NewWorker(pool *pgxpool.Pool, logger *slog.Logger) *Worker {
return &Worker{pool: pool, logger: logger, tick: 6 * time.Hour}
}
// Run rebuilds once at startup (so edges exist without waiting a full tick),
// then every w.tick until ctx is cancelled. Runs in its own goroutine, so a
// slow initial rebuild never blocks boot.
func (w *Worker) Run(ctx context.Context) {
if err := w.rebuild(ctx); err != nil {
w.logger.Error("coplay: initial rebuild failed", "err", err)
}
t := time.NewTicker(w.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := w.rebuild(ctx); err != nil {
w.logger.Error("coplay: rebuild failed", "err", err)
}
}
}
}
// rebuild atomic-replaces the user_cooccurrence edge set inside one
// transaction — delete the old edges, recompute from current play history — so
// the candidate pool never observes a half-built set.
func (w *Worker) rebuild(ctx context.Context) error {
tx, err := w.pool.Begin(ctx)
if err != nil {
return err
}
defer func() { _ = tx.Rollback(ctx) }()
q := dbq.New(tx)
if err := q.DeleteArtistCoplayEdges(ctx); err != nil {
return err
}
if err := q.InsertArtistCoplayEdges(ctx, minJaccard); err != nil {
return err
}
if err := tx.Commit(ctx); err != nil {
return err
}
w.logger.Debug("coplay: edges rebuilt")
return nil
}
+162
View File
@@ -0,0 +1,162 @@
package coplay
import (
"context"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
func testPool(t *testing.T) *pgxpool.Pool {
t.Helper()
if testing.Short() {
t.Skip("skipping in -short mode")
}
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
}
if err := db.Migrate(dsn, slog.New(slog.NewTextHandler(io.Discard, nil))); err != nil {
t.Fatalf("migrate: %v", err)
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(pool.Close)
dbtest.ResetDB(t, pool)
return pool
}
func discardLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func seedUser(t *testing.T, q *dbq.Queries, name string) pgtype.UUID {
t.Helper()
u, err := q.CreateUser(context.Background(), dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + name, PasswordHash: "x", ApiToken: name + "-tok", IsAdmin: false,
})
if err != nil {
t.Fatalf("seed user %s: %v", name, err)
}
return u.ID
}
// seedArtistTrack creates an artist + album + one track, returning both ids.
func seedArtistTrack(t *testing.T, q *dbq.Queries, name string) (pgtype.UUID, pgtype.UUID) {
t.Helper()
ctx := context.Background()
a, err := q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name})
if err != nil {
t.Fatalf("seed artist: %v", err)
}
al, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: a.ID})
if err != nil {
t.Fatalf("seed album: %v", err)
}
tr, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
Title: name, AlbumID: al.ID, ArtistID: a.ID,
DurationMs: 1000, FilePath: "/tmp/coplay-" + name + ".mp3",
FileSize: 1, FileFormat: "mp3",
})
if err != nil {
t.Fatalf("seed track: %v", err)
}
return a.ID, tr.ID
}
func play(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID) {
t.Helper()
ctx := context.Background()
at := time.Now()
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx,
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, 'coplay-test') RETURNING id`,
userID, at).Scan(&sessionID); err != nil {
t.Fatalf("insert play_session: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped)
VALUES ($1, $2, $3, $4, false)`,
userID, trackID, sessionID, at); err != nil {
t.Fatalf("insert play_event: %v", err)
}
}
func coplayEdgeCount(t *testing.T, pool *pgxpool.Pool) int {
t.Helper()
var n int
if err := pool.QueryRow(context.Background(),
`SELECT count(*) FROM artist_similarity WHERE source='user_cooccurrence'`).Scan(&n); err != nil {
t.Fatalf("count edges: %v", err)
}
return n
}
// TestCoplayWorker_BuildsEdges: two users who both play two artists produce a
// perfect-overlap co-play edge (Jaccard 1.0) in each direction.
func TestCoplayWorker_BuildsEdges(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
u1 := seedUser(t, q, "cop1")
u2 := seedUser(t, q, "cop2")
aA, tA := seedArtistTrack(t, q, "ArtA")
aB, tB := seedArtistTrack(t, q, "ArtB")
for _, u := range []pgtype.UUID{u1, u2} {
play(t, pool, u, tA)
play(t, pool, u, tB)
}
if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil {
t.Fatalf("rebuild: %v", err)
}
var score float64
if err := pool.QueryRow(ctx,
`SELECT score FROM artist_similarity
WHERE artist_a_id=$1 AND artist_b_id=$2 AND source='user_cooccurrence'`,
aA, aB).Scan(&score); err != nil {
t.Fatalf("expected co-play edge ArtA→ArtB: %v", err)
}
// Jaccard of perfectly-overlapping player sets (both users play both):
// coplayers=2, players each=2 → 2/(2+2-2) = 1.0.
if score != 1.0 {
t.Errorf("edge score = %v, want 1.0 (perfect overlap)", score)
}
if got := coplayEdgeCount(t, pool); got != 2 {
t.Errorf("edge count = %d, want 2 (A→B and B→A)", got)
}
}
// TestCoplayWorker_SingleUserNoEdges: one user's own co-listening yields no
// edges — the >= 2 distinct-co-player gate keeps single-user servers empty.
func TestCoplayWorker_SingleUserNoEdges(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
q := dbq.New(pool)
u := seedUser(t, q, "solo")
_, tA := seedArtistTrack(t, q, "SoloA")
_, tB := seedArtistTrack(t, q, "SoloB")
play(t, pool, u, tA)
play(t, pool, u, tB)
if err := NewWorker(pool, discardLogger()).rebuild(ctx); err != nil {
t.Fatalf("rebuild: %v", err)
}
if got := coplayEdgeCount(t, pool); got != 0 {
t.Errorf("single-user edge count = %d, want 0 (>= 2 co-player gate)", got)
}
}
+78
View File
@@ -0,0 +1,78 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: coplay.sql
package dbq
import (
"context"
)
const deleteArtistCoplayEdges = `-- name: DeleteArtistCoplayEdges :exec
DELETE FROM artist_similarity WHERE source = 'user_cooccurrence'
`
// Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a
// single shared-library, multi-user server (no per-user library ACL), so the
// "household" is the whole instance's user set. These queries recompute
// artistartist co-occurrence edges from play_events and store them in
// artist_similarity under the pre-provisioned source = 'user_cooccurrence'
// (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then
// re-INSERTs so edges that fall below threshold disappear. Single-user servers
// produce no edges (the >= 2 co-player gate), so the arm is empty there.
func (q *Queries) DeleteArtistCoplayEdges(ctx context.Context) error {
_, err := q.db.Exec(ctx, deleteArtistCoplayEdges)
return err
}
const insertArtistCoplayEdges = `-- name: InsertArtistCoplayEdges :exec
WITH user_artists AS (
SELECT DISTINCT pe.user_id, t.artist_id
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
),
artist_players AS (
SELECT artist_id, count(*)::float8 AS players
FROM user_artists
GROUP BY artist_id
),
pairs AS (
SELECT ua.artist_id AS a_id,
ub.artist_id AS b_id,
count(*)::float8 AS coplayers
FROM user_artists ua
JOIN user_artists ub
ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id
GROUP BY ua.artist_id, ub.artist_id
HAVING count(*) >= 2
),
scored AS (
SELECT p.a_id, p.b_id,
p.coplayers / (pa.players + pb.players - p.coplayers) AS score
FROM pairs p
JOIN artist_players pa ON pa.artist_id = p.a_id
JOIN artist_players pb ON pb.artist_id = p.b_id
)
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
SELECT a_id, b_id, score, 'user_cooccurrence', now()
FROM scored
WHERE score >= $1
ON CONFLICT (artist_a_id, artist_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at
`
// Two artists are "co-played" when the same users play both. score is the
// Jaccard of their distinct-player sets — coplayers / (playersA + playersB
// coplayers) in (0,1] — which controls for globally-popular artists (an artist
// everyone plays would otherwise co-occur with everything). Only pairs with
// >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak
// edges and self-limits hub artists, whose large denominators drag their
// Jaccard down) are kept. Completed plays only, 365-day window.
func (q *Queries) InsertArtistCoplayEdges(ctx context.Context, score float64) error {
_, err := q.db.Exec(ctx, insertArtistCoplayEdges, score)
return err
}
+66
View File
@@ -211,3 +211,69 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
}
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
}
+24 -14
View File
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
}
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events
WHERE user_id = $1 AND ended_at IS NULL
ORDER BY started_at DESC
LIMIT 1
@@ -80,12 +80,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
&i.DeviceClass,
)
return i, err
}
const getPlayEventByID = `-- name: GetPlayEventByID :one
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events WHERE id = $1
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events WHERE id = $1
`
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
@@ -106,6 +107,7 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
&i.DeviceClass,
)
return i, err
}
@@ -144,19 +146,22 @@ func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPi
const insertPlayEvent = `-- name: InsertPlayEvent :one
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind
) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
device_class
) VALUES ($1, $2, $3, $4, $5, $6, $7::text,
$8::text)
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
`
type InsertPlayEventParams struct {
UserID pgtype.UUID
TrackID pgtype.UUID
SessionID pgtype.UUID
StartedAt pgtype.Timestamptz
ClientID *string
Source *string
PickKind *string
UserID pgtype.UUID
TrackID pgtype.UUID
SessionID pgtype.UUID
StartedAt pgtype.Timestamptz
ClientID *string
Source *string
PickKind *string
DeviceClass *string
}
// pick_kind is non-NULL only for system-playlist plays whose track was
@@ -171,6 +176,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
arg.ClientID,
arg.Source,
arg.PickKind,
arg.DeviceClass,
)
var i PlayEvent
err := row.Scan(
@@ -188,6 +194,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
&i.DeviceClass,
)
return i, err
}
@@ -254,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
}
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
WHERE pe.session_id = $1
AND pe.started_at < $2
@@ -296,6 +303,8 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -331,7 +340,7 @@ SET ended_at = $2,
completion_ratio = $4,
was_skipped = $5
WHERE id = $1
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
`
type UpdatePlayEventEndedParams struct {
@@ -369,6 +378,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
&i.ScrobbledAt,
&i.Source,
&i.PickKind,
&i.DeviceClass,
)
return i, err
}
+3 -1
View File
@@ -14,7 +14,7 @@ import (
const listUserHistory = `-- name: ListUserHistory :many
SELECT pe.id AS event_id,
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,
artists.name AS artist_name
FROM play_events pe
@@ -77,6 +77,8 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
+3 -1
View File
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
}
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
WHERE l.user_id = $1
ORDER BY l.liked_at DESC
@@ -297,6 +297,8 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
+67 -25
View File
@@ -384,6 +384,7 @@ type PlayEvent struct {
ScrobbledAt pgtype.Timestamptz
Source *string
PickKind *string
DeviceClass *string
}
type PlaySession struct {
@@ -446,16 +447,17 @@ type RecommendationTuningAudit struct {
}
type RecommendationWeightProfile struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
UpdatedAt pgtype.Timestamptz
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
UpdatedAt pgtype.Timestamptz
ContextTimeWeight float64
}
type RegistrationSetting struct {
@@ -532,6 +534,21 @@ type SystemPlaylistRun struct {
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 {
UserID pgtype.UUID
ArtistID pgtype.UUID
@@ -539,6 +556,20 @@ type TasteProfileArtist struct {
UpdatedAt pgtype.Timestamptz
}
type TasteProfileEra struct {
UserID pgtype.UUID
Era string
Weight float64
UpdatedAt pgtype.Timestamptz
}
type TasteProfileMood struct {
UserID pgtype.UUID
Mood string
Weight float64
UpdatedAt pgtype.Timestamptz
}
type TasteProfileTag struct {
UserID pgtype.UUID
Tag string
@@ -553,24 +584,29 @@ type TasteTuning struct {
EngagementNeutral float64
EngagementFull float64
UpdatedAt pgtype.Timestamptz
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
type Track struct {
ID pgtype.UUID
Title string
AlbumID pgtype.UUID
ArtistID pgtype.UUID
TrackNumber *int32
DiscNumber *int32
DurationMs int32
FilePath string
FileSize int64
FileFormat string
Bitrate *int32
Mbid *string
Genre *string
AddedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
ID pgtype.UUID
Title string
AlbumID pgtype.UUID
ArtistID pgtype.UUID
TrackNumber *int32
DiscNumber *int32
DurationMs int32
FilePath string
FileSize int64
FileFormat string
Bitrate *int32
Mbid *string
Genre *string
AddedAt pgtype.Timestamptz
UpdatedAt pgtype.Timestamptz
TagSource *string
TagSourcesVersion int32
}
type TrackSimilarity struct {
@@ -581,6 +617,12 @@ type TrackSimilarity struct {
FetchedAt pgtype.Timestamptz
}
type TrackTag struct {
TrackID pgtype.UUID
Tag string
Weight float64
}
type User struct {
ID pgtype.UUID
Username string
+150 -7
View File
@@ -11,6 +11,110 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const getLatestPlayDeviceClassForUser = `-- name: GetLatestPlayDeviceClassForUser :one
SELECT device_class
FROM play_events
WHERE user_id = $1
ORDER BY started_at DESC
LIMIT 1
`
// The device_class of the user's most recent play (#1551), used as the
// "current device" for radio context conditioning. NULL when the latest play
// predates device capture or came from a client that didn't send one.
func (q *Queries) GetLatestPlayDeviceClassForUser(ctx context.Context, userID pgtype.UUID) (*string, error) {
row := q.db.QueryRow(ctx, getLatestPlayDeviceClassForUser, userID)
var device_class *string
err := row.Scan(&device_class)
return device_class, err
}
const listArtistContextPlayCountsForUser = `-- name: ListArtistContextPlayCountsForUser :many
WITH tz AS (
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
FROM users u WHERE u.id = $1
),
now_cell AS (
SELECT
CASE
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM tz
),
plays AS (
SELECT t.artist_id,
pe.device_class,
CASE
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
CROSS JOIN tz
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
)
SELECT p.artist_id,
count(*) AS total_plays,
count(*) FILTER (
WHERE p.daypart = (SELECT daypart FROM now_cell)
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
AND ($2::text = '' OR p.device_class = $2::text)
) AS cell_plays
FROM plays p
GROUP BY p.artist_id
`
type ListArtistContextPlayCountsForUserParams struct {
ID pgtype.UUID
Column2 string
}
type ListArtistContextPlayCountsForUserRow struct {
ArtistID pgtype.UUID
TotalPlays int64
CellPlays int64
}
// Per-artist completed-play counts split by whether each play falls in the
// CURRENT context cell, in the user's local timezone (#1531). The cell is
// daypart × weekday-type, optionally narrowed by device class (#1551): when
// $2 (the current device) is ”, the device dimension is ignored (identical to
// the time-only #1531 behaviour, used by the daily mixes which have no device);
// when $2 is set (radio), a play only counts in the cell if its device_class
// matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
// window bounds cost. Daypart buckets: night [22,5) morning [5,12)
// afternoon [12,17) evening [17,22). Weekend = ISO days 67 (Sat/Sun).
func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, arg ListArtistContextPlayCountsForUserParams) ([]ListArtistContextPlayCountsForUserRow, error) {
rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, arg.ID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListArtistContextPlayCountsForUserRow
for rows.Next() {
var i ListArtistContextPlayCountsForUserRow
if err := rows.Scan(&i.ArtistID, &i.TotalPlays, &i.CellPlays); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
WITH user_plays AS (
SELECT t.artist_id, max(pe.started_at) AS last_started
@@ -104,7 +208,7 @@ WITH plays AS (
WHERE user_id = $2 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
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,
artists.name AS artist_name
FROM plays p
@@ -161,6 +265,8 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -181,7 +287,7 @@ WITH plays AS (
WHERE user_id = $1 AND was_skipped = false
GROUP BY track_id
)
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
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,
artists.name AS artist_name
FROM plays p
@@ -240,6 +346,8 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -577,12 +685,14 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
const loadRadioCandidates = `-- name: LoadRadioCandidates :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,
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,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count
pe.skip_count,
al.release_date AS release_date
FROM tracks t
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT
@@ -616,6 +726,7 @@ type LoadRadioCandidatesRow struct {
LastPlayedAt pgtype.Timestamptz
PlayCount int64
SkipCount int64
ReleaseDate pgtype.Date
}
// Returns all tracks except the seed and any played by the user within
@@ -650,10 +761,13 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.IsLiked,
&i.LastPlayedAt,
&i.PlayCount,
&i.SkipCount,
&i.ReleaseDate,
); err != nil {
return nil, err
}
@@ -748,6 +862,22 @@ taste_overlap AS (
ORDER BY tpa.weight DESC, t.id
LIMIT $10
),
coplay_artists AS (
-- Household co-play (#1533): tracks by artists co-played across the
-- instance with the seed's artist (source='user_cooccurrence', built by
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
-- similar_artists since it's artist-level.
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
FROM artist_similarity asim
JOIN tracks t ON t.artist_id = asim.artist_b_id
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'user_cooccurrence'
AND t.id NOT IN (SELECT id FROM excluded_ids)
AND t.id <> $2
ORDER BY asim.score DESC, random()
LIMIT $11
),
random_fill AS (
SELECT t.id AS track_id, 0.0::float8 AS sim_score
FROM tracks t
@@ -759,16 +889,18 @@ random_fill AS (
UNION SELECT track_id FROM tag_overlap
UNION SELECT track_id FROM likes_overlap
UNION SELECT track_id FROM taste_overlap
UNION SELECT track_id FROM coplay_artists
)
ORDER BY random()
LIMIT $9
)
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,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count,
al.release_date AS release_date,
COALESCE(max(u.sim_score), 0.0) AS similarity_score
FROM (
SELECT track_id, sim_score FROM lb_similar
@@ -776,9 +908,11 @@ FROM (
UNION ALL SELECT track_id, sim_score FROM tag_overlap
UNION ALL SELECT track_id, sim_score FROM likes_overlap
UNION ALL SELECT track_id, sim_score FROM taste_overlap
UNION ALL SELECT track_id, sim_score FROM coplay_artists
UNION ALL SELECT track_id, sim_score FROM random_fill
) u
JOIN tracks t ON t.id = u.track_id
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at,
@@ -790,7 +924,8 @@ LEFT JOIN LATERAL (
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.added_at, t.updated_at,
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
al.release_date
`
type LoadRadioCandidatesV2Params struct {
@@ -804,6 +939,7 @@ type LoadRadioCandidatesV2Params struct {
Limit_4 int32
Limit_5 int32
Limit_6 int32
Limit_7 int32
}
type LoadRadioCandidatesV2Row struct {
@@ -812,6 +948,7 @@ type LoadRadioCandidatesV2Row struct {
LastPlayedAt pgtype.Timestamptz
PlayCount int64
SkipCount int64
ReleaseDate pgtype.Date
SimilarityScore interface{}
}
@@ -823,7 +960,9 @@ type LoadRadioCandidatesV2Row struct {
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
// positively-weighted taste-profile artists, so taste-relevant tracks
// enter the pool even when the similarity/random arms miss them; scored
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
// $11 coplay_artists K (#1533 — tracks by artists co-played across the
// instance with the seed's artist; source='user_cooccurrence').
//
// Returns same shape as LoadRadioCandidates plus similarity_score column.
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
@@ -838,6 +977,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
arg.Limit_4,
arg.Limit_5,
arg.Limit_6,
arg.Limit_7,
)
if err != nil {
return nil, err
@@ -862,10 +1002,13 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.IsLiked,
&i.LastPlayedAt,
&i.PlayCount,
&i.SkipCount,
&i.ReleaseDate,
&i.SimilarityScore,
); err != nil {
return nil, err
+65 -35
View File
@@ -10,7 +10,7 @@ import (
)
const getTasteTuning = `-- name: GetTasteTuning :one
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at FROM taste_tuning WHERE singleton = true
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
`
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
@@ -23,6 +23,9 @@ func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
&i.EnrichedTagScale,
&i.EraScale,
&i.MoodScale,
)
return i, err
}
@@ -80,7 +83,7 @@ func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]Recommend
}
const listWeightProfiles = `-- name: ListWeightProfiles :many
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight FROM recommendation_weight_profiles ORDER BY profile
`
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
@@ -103,6 +106,7 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
&i.ContextTimeWeight,
); err != nil {
return nil, err
}
@@ -120,9 +124,12 @@ UPDATE taste_tuning
engagement_hard_skip = $2,
engagement_neutral = $3,
engagement_full = $4,
enriched_tag_scale = $5,
era_scale = $6,
mood_scale = $7,
updated_at = now()
WHERE singleton = true
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale
`
type UpdateTasteTuningParams struct {
@@ -130,6 +137,9 @@ type UpdateTasteTuningParams struct {
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
@@ -138,6 +148,9 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
arg.EnrichedTagScale,
arg.EraScale,
arg.MoodScale,
)
var i TasteTuning
err := row.Scan(
@@ -147,35 +160,40 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
&i.EngagementNeutral,
&i.EngagementFull,
&i.UpdatedAt,
&i.EnrichedTagScale,
&i.EraScale,
&i.MoodScale,
)
return i, err
}
const updateWeightProfile = `-- name: UpdateWeightProfile :one
UPDATE recommendation_weight_profiles
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
updated_at = now()
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
context_time_weight = $10,
updated_at = now()
WHERE profile = $1
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight
`
type UpdateWeightProfileParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
ContextTimeWeight float64
}
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
@@ -189,6 +207,7 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
arg.ContextTimeWeight,
)
var i RecommendationWeightProfile
err := row.Scan(
@@ -202,6 +221,7 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
&i.SimilarityWeight,
&i.TasteWeight,
&i.UpdatedAt,
&i.ContextTimeWeight,
)
return i, err
}
@@ -209,8 +229,9 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full
) VALUES (true, $1, $2, $3, $4)
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
mood_scale
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (singleton) DO NOTHING
`
@@ -219,6 +240,9 @@ type UpsertTasteTuningDefaultsParams struct {
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
@@ -227,6 +251,9 @@ func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTaste
arg.EngagementHardSkip,
arg.EngagementNeutral,
arg.EngagementFull,
arg.EnrichedTagScale,
arg.EraScale,
arg.MoodScale,
)
return err
}
@@ -235,21 +262,23 @@ const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
jitter_magnitude, context_weight, similarity_weight, taste_weight,
context_time_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (profile) DO NOTHING
`
type UpsertWeightProfileDefaultsParams struct {
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
Profile string
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
ContextTimeWeight float64
}
// Recommendation tuning lab queries (#1250). Seeding happens via the
@@ -267,6 +296,7 @@ func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWei
arg.ContextWeight,
arg.SimilarityWeight,
arg.TasteWeight,
arg.ContextTimeWeight,
)
return err
}
+5 -4
View File
@@ -350,7 +350,7 @@ SELECT c.artist_id,
FROM chosen c
LEFT JOIN liked l ON l.artist_id = c.artist_id
ORDER BY score DESC, c.artist_id
LIMIT 5
LIMIT 12
`
type PickSeedArtistsRow struct {
@@ -359,7 +359,7 @@ type PickSeedArtistsRow struct {
Tier int32
}
// Top-5 most-engaged distinct artist candidates, tiered so the
// Top-12 most-engaged distinct artist candidates, tiered so the
// "Songs like X" mixes never silently vanish (#1255): the old hard
// 7-day window emptied the seed pool after a quiet week, and the
// daily atomic-replace build then deleted every existing mix until
@@ -375,8 +375,9 @@ type PickSeedArtistsRow struct {
// 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 5
// daily-deterministically and takes 3 so the mix set rotates.
// 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) {
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
if err != nil {
+157
View File
@@ -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(&current_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(&current_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(&current_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
}
+156 -11
View File
@@ -20,6 +20,24 @@ func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID p
return err
}
const deleteTasteProfileErasForUser = `-- name: DeleteTasteProfileErasForUser :exec
DELETE FROM taste_profile_eras WHERE user_id = $1
`
func (q *Queries) DeleteTasteProfileErasForUser(ctx context.Context, userID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTasteProfileErasForUser, userID)
return err
}
const deleteTasteProfileMoodsForUser = `-- name: DeleteTasteProfileMoodsForUser :exec
DELETE FROM taste_profile_moods WHERE user_id = $1
`
func (q *Queries) DeleteTasteProfileMoodsForUser(ctx context.Context, userID pgtype.UUID) error {
_, err := q.db.Exec(ctx, deleteTasteProfileMoodsForUser, userID)
return err
}
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
DELETE FROM taste_profile_tags WHERE user_id = $1
`
@@ -45,6 +63,38 @@ func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteP
return err
}
const insertTasteProfileEra = `-- name: InsertTasteProfileEra :exec
INSERT INTO taste_profile_eras (user_id, era, weight)
VALUES ($1, $2, $3)
`
type InsertTasteProfileEraParams struct {
UserID pgtype.UUID
Era string
Weight float64
}
func (q *Queries) InsertTasteProfileEra(ctx context.Context, arg InsertTasteProfileEraParams) error {
_, err := q.db.Exec(ctx, insertTasteProfileEra, arg.UserID, arg.Era, arg.Weight)
return err
}
const insertTasteProfileMood = `-- name: InsertTasteProfileMood :exec
INSERT INTO taste_profile_moods (user_id, mood, weight)
VALUES ($1, $2, $3)
`
type InsertTasteProfileMoodParams struct {
UserID pgtype.UUID
Mood string
Weight float64
}
func (q *Queries) InsertTasteProfileMood(ctx context.Context, arg InsertTasteProfileMoodParams) error {
_, err := q.db.Exec(ctx, insertTasteProfileMood, arg.UserID, arg.Mood, arg.Weight)
return err
}
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
INSERT INTO taste_profile_tags (user_id, tag, weight)
VALUES ($1, $2, $3)
@@ -87,19 +137,23 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
}
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
SELECT t.artist_id, t.genre
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
JOIN albums a ON a.id = t.album_id
WHERE gl.user_id = $1
`
type ListLikedTrackTasteInputsForUserRow struct {
ArtistID pgtype.UUID
Genre *string
TrackID pgtype.UUID
ArtistID pgtype.UUID
Genre *string
ReleaseDate pgtype.Date
}
// (artist_id, genre) for each track the user has explicitly liked. Feeds the
// track-like bonus into the liked track's artist and tags.
// (track_id, artist_id, genre, release_date) for each track the user has
// explicitly liked. Feeds the track-like bonus into the liked track's artist,
// tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
if err != nil {
@@ -109,7 +163,12 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
var items []ListLikedTrackTasteInputsForUserRow
for rows.Next() {
var i ListLikedTrackTasteInputsForUserRow
if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil {
if err := rows.Scan(
&i.TrackID,
&i.ArtistID,
&i.Genre,
&i.ReleaseDate,
); err != nil {
return nil, err
}
items = append(items, i)
@@ -123,8 +182,10 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
SELECT
t.id AS track_id,
t.artist_id,
t.genre,
a.release_date,
LEAST(GREATEST(
COALESCE(pe.completion_ratio,
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
@@ -132,6 +193,7 @@ SELECT
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS (
@@ -146,10 +208,12 @@ type ListPlayEngagementInputsForUserParams struct {
}
type ListPlayEngagementInputsForUserRow struct {
ArtistID pgtype.UUID
Genre *string
Completion float64
AgeDays float64
TrackID pgtype.UUID
ArtistID pgtype.UUID
Genre *string
ReleaseDate pgtype.Date
Completion float64
AgeDays float64
}
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
@@ -158,7 +222,8 @@ type ListPlayEngagementInputsForUserRow struct {
// One row per play in the decay-relevant window. completion is the play's
// completion ratio (precomputed column when present, else duration_played /
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
// is split into tags in Go. Quarantined tracks are excluded.
// is split into tags in Go; release_date derives the decade for the era
// facet (#1530). Quarantined tracks are excluded.
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
if err != nil {
@@ -169,8 +234,10 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
for rows.Next() {
var i ListPlayEngagementInputsForUserRow
if err := rows.Scan(
&i.TrackID,
&i.ArtistID,
&i.Genre,
&i.ReleaseDate,
&i.Completion,
&i.AgeDays,
); err != nil {
@@ -223,6 +290,84 @@ func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTa
return items, nil
}
const listTasteProfileErasForUser = `-- name: ListTasteProfileErasForUser :many
SELECT era, weight
FROM taste_profile_eras
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2
`
type ListTasteProfileErasForUserParams struct {
UserID pgtype.UUID
Limit int32
}
type ListTasteProfileErasForUserRow struct {
Era string
Weight float64
}
// Top-weighted taste eras (#1530); consumed by the scorer's era term.
func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTasteProfileErasForUserParams) ([]ListTasteProfileErasForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileErasForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteProfileErasForUserRow
for rows.Next() {
var i ListTasteProfileErasForUserRow
if err := rows.Scan(&i.Era, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasteProfileMoodsForUser = `-- name: ListTasteProfileMoodsForUser :many
SELECT mood, weight
FROM taste_profile_moods
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2
`
type ListTasteProfileMoodsForUserParams struct {
UserID pgtype.UUID
Limit int32
}
type ListTasteProfileMoodsForUserRow struct {
Mood string
Weight float64
}
// Top-weighted taste moods (#1534); consumed by the scorer's mood term.
func (q *Queries) ListTasteProfileMoodsForUser(ctx context.Context, arg ListTasteProfileMoodsForUserParams) ([]ListTasteProfileMoodsForUserRow, error) {
rows, err := q.db.Query(ctx, listTasteProfileMoodsForUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteProfileMoodsForUserRow
for rows.Next() {
var i ListTasteProfileMoodsForUserRow
if err := rows.Scan(&i.Mood, &i.Weight); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
SELECT tag, weight
FROM taste_profile_tags
+222
View File
@@ -0,0 +1,222 @@
// 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 listTrackTagsForTracks = `-- name: ListTrackTagsForTracks :many
SELECT tt.track_id, tt.tag
FROM track_tags tt
WHERE tt.track_id = ANY($1::uuid[])
`
type ListTrackTagsForTracksRow struct {
TrackID pgtype.UUID
Tag string
}
// Enriched tags for a set of candidate tracks (#1534), so the scorer can
// derive each candidate's mood buckets at scoring time. One row per (track,
// tag); the caller filters to mood words via the internal/mood vocabulary.
func (q *Queries) ListTrackTagsForTracks(ctx context.Context, dollar_1 []pgtype.UUID) ([]ListTrackTagsForTracksRow, error) {
rows, err := q.db.Query(ctx, listTrackTagsForTracks, dollar_1)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTrackTagsForTracksRow
for rows.Next() {
var i ListTrackTagsForTracksRow
if err := rows.Scan(&i.TrackID, &i.Tag); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTracksMissingTags = `-- name: ListTracksMissingTags :many
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
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
ArtistMbid *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, title + artist name for name-based fallback, and the
// artist MBID (nullable) for MusicBrainz's artist-tag fallback (#1519).
// $1 = current tag_sources_version, $2 = limit.
func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissingTagsParams) ([]ListTracksMissingTagsRow, error) {
rows, err := q.db.Query(ctx, listTracksMissingTags, arg.TagSourcesVersion, arg.Limit)
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,
&i.ArtistMbid,
); 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
}
+24 -8
View File
@@ -90,7 +90,7 @@ func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackR
}
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) {
@@ -112,12 +112,14 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
)
return i, err
}
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) {
@@ -139,12 +141,14 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
)
return i, err
}
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
@@ -174,6 +178,8 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -186,7 +192,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
}
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,
artists.name AS artist_name
FROM tracks t
@@ -242,6 +248,8 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -256,7 +264,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
}
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,
artists.name AS artist_name
FROM tracks t
@@ -309,6 +317,8 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
&i.Track.Genre,
&i.Track.AddedAt,
&i.Track.UpdatedAt,
&i.Track.TagSource,
&i.Track.TagSourcesVersion,
&i.AlbumTitle,
&i.ArtistName,
); err != nil {
@@ -323,7 +333,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
}
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
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
@@ -365,6 +375,8 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -412,7 +424,7 @@ func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32
}
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 || '%'
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
@@ -461,6 +473,8 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
); err != nil {
return nil, err
}
@@ -508,7 +522,7 @@ ON CONFLICT (file_path) DO UPDATE SET
mbid = EXCLUDED.mbid,
genre = EXCLUDED.genre,
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 {
@@ -559,6 +573,8 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
&i.Genre,
&i.AddedAt,
&i.UpdatedAt,
&i.TagSource,
&i.TagSourcesVersion,
)
return i, err
}
@@ -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);
@@ -0,0 +1,2 @@
-- Reverse 0044_taste_tuning_enriched_tag_scale.up.sql.
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS enriched_tag_scale;
@@ -0,0 +1,6 @@
-- Expose EnrichedTagScale in the tuning lab (#1520). Adds the enriched-tag
-- weight knob to the taste_tuning singleton so operators can dial how much
-- folksonomy tags (#1490) count vs raw ID3 genre. DEFAULT 0.5 backfills the
-- existing row and matches taste.DefaultConfig().EnrichedTagScale.
ALTER TABLE taste_tuning
ADD COLUMN enriched_tag_scale double precision NOT NULL DEFAULT 0.5;
@@ -0,0 +1,2 @@
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS era_scale;
DROP TABLE IF EXISTS taste_profile_eras;
@@ -0,0 +1,26 @@
-- 0045_taste_profile_eras.up.sql — era/decade taste facet (#1530,
-- milestone #160 Opt 2). A third taste facet alongside artists + tags:
-- signed weights over decade buckets ("1990s") derived from
-- albums.release_date, rebuilt daily by internal/taste next to the
-- artist/tag facets. Weight is a signed float — positive = drawn to
-- that era, negative = passively avoided; magnitude reflects decayed
-- engagement. Consumed by the recommendation scorer's taste match.
-- Mirrors taste_profile_tags: CASCADE on user delete, indexed by
-- (user_id, weight DESC) so "top eras" reads stay cheap.
CREATE TABLE taste_profile_eras (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
era text NOT NULL,
weight double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, era)
);
CREATE INDEX taste_profile_eras_user_weight_idx
ON taste_profile_eras (user_id, weight DESC);
-- Era-facet build knob, tunable in the lab (mirrors enriched_tag_scale,
-- #1520): scales how strongly a decade-play imprints on the profile.
-- DEFAULT 0.5 backfills the existing singleton and matches
-- taste.DefaultConfig().EraScale; 0 disables the era facet entirely.
ALTER TABLE taste_tuning
ADD COLUMN era_scale double precision NOT NULL DEFAULT 0.5;
@@ -0,0 +1 @@
ALTER TABLE recommendation_weight_profiles DROP COLUMN IF EXISTS context_time_weight;
@@ -0,0 +1,12 @@
-- 0046_context_time_weight.up.sql — time-of-day/weekday context conditioning
-- (#1531, milestone #160 Opt 3). Adds a per-profile scoring weight for the new
-- context-affinity term: how strongly a candidate is boosted when its artist's
-- play history concentrates in the current daypart × weekday-type cell (in the
-- user's local timezone). Mirrors the other ScoringWeights columns.
--
-- DEFAULT 1.0 backfills both existing profile rows to a modest on-value; the
-- Go ShippedRadioWeights/ShippedDailyMixWeights carry the same 1.0 so fresh
-- installs seed identically. Reconcile only seeds MISSING rows (ON CONFLICT DO
-- NOTHING), so existing rows rely on this DEFAULT until an operator resets.
ALTER TABLE recommendation_weight_profiles
ADD COLUMN context_time_weight double precision NOT NULL DEFAULT 1.0;
@@ -0,0 +1,2 @@
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS mood_scale;
DROP TABLE IF EXISTS taste_profile_moods;
@@ -0,0 +1,25 @@
-- 0047_taste_profile_moods.up.sql — mood taste facet (#1534, milestone #160
-- Opt 2b). A fourth taste facet alongside artists + tags + eras: signed weights
-- over canonical mood buckets (melancholic / energetic / chill / …) derived
-- from a track's enriched folksonomy tags (#1490) via the internal/mood
-- vocabulary, rebuilt daily. Weight is a signed float — positive = drawn to
-- that mood, negative = passively avoided. Coverage is partial (grows with tag
-- enrichment), so it's a supplement, not a foundation. Mirrors
-- taste_profile_tags: CASCADE on user delete, indexed by (user_id, weight DESC).
CREATE TABLE taste_profile_moods (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mood text NOT NULL,
weight double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, mood)
);
CREATE INDEX taste_profile_moods_user_weight_idx
ON taste_profile_moods (user_id, weight DESC);
-- Mood-facet build knob, tunable in the lab (mirrors era_scale / enriched_tag_
-- scale): scales how strongly a mood-tagged play imprints on the profile.
-- DEFAULT 0.5 backfills the existing singleton and matches
-- taste.DefaultConfig().MoodScale; 0 disables the mood facet entirely.
ALTER TABLE taste_tuning
ADD COLUMN mood_scale double precision NOT NULL DEFAULT 0.5;
@@ -0,0 +1 @@
ALTER TABLE play_events DROP COLUMN IF EXISTS device_class;
@@ -0,0 +1,9 @@
-- 0048_play_events_device_class.up.sql — device-class context conditioning
-- (#1551, milestone #160 Opt 3b). Records which class of device a play came
-- from (mobile / web / …), sent by the client on play-start and normalized
-- server-side. Extends the #1531 time-of-day/weekday affinity cell to
-- (daypart × weekday × device) for the radio path. Nullable + no CHECK: values
-- are normalized in Go (avoids a CHECK-migration each time a client class is
-- added, rule #36), and historical rows stay NULL → excluded from the device
-- dimension (a graceful cold-start; the facet ramps as new plays land).
ALTER TABLE play_events ADD COLUMN device_class text;
+55
View File
@@ -0,0 +1,55 @@
-- Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a
-- single shared-library, multi-user server (no per-user library ACL), so the
-- "household" is the whole instance's user set. These queries recompute
-- artistartist co-occurrence edges from play_events and store them in
-- artist_similarity under the pre-provisioned source = 'user_cooccurrence'
-- (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then
-- re-INSERTs so edges that fall below threshold disappear. Single-user servers
-- produce no edges (the >= 2 co-player gate), so the arm is empty there.
-- name: DeleteArtistCoplayEdges :exec
DELETE FROM artist_similarity WHERE source = 'user_cooccurrence';
-- name: InsertArtistCoplayEdges :exec
-- Two artists are "co-played" when the same users play both. score is the
-- Jaccard of their distinct-player sets — coplayers / (playersA + playersB
-- coplayers) in (0,1] — which controls for globally-popular artists (an artist
-- everyone plays would otherwise co-occur with everything). Only pairs with
-- >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak
-- edges and self-limits hub artists, whose large denominators drag their
-- Jaccard down) are kept. Completed plays only, 365-day window.
WITH user_artists AS (
SELECT DISTINCT pe.user_id, t.artist_id
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
),
artist_players AS (
SELECT artist_id, count(*)::float8 AS players
FROM user_artists
GROUP BY artist_id
),
pairs AS (
SELECT ua.artist_id AS a_id,
ub.artist_id AS b_id,
count(*)::float8 AS coplayers
FROM user_artists ua
JOIN user_artists ub
ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id
GROUP BY ua.artist_id, ub.artist_id
HAVING count(*) >= 2
),
scored AS (
SELECT p.a_id, p.b_id,
p.coplayers / (pa.players + pb.players - p.coplayers) AS score
FROM pairs p
JOIN artist_players pa ON pa.artist_id = p.a_id
JOIN artist_players pb ON pb.artist_id = p.b_id
)
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at)
SELECT a_id, b_id, score, 'user_cooccurrence', now()
FROM scored
WHERE score >= $1
ON CONFLICT (artist_a_id, artist_b_id, source)
DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at;
+34
View File
@@ -102,3 +102,37 @@ SELECT t.id, t.album_id, t.artist_id
)
ORDER BY md5(t.id::text || $2::text)
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;
+4 -2
View File
@@ -28,8 +28,10 @@ LIMIT 1;
-- found (with a stamped kind) in the user's live snapshot for that
-- variant at ingestion time (#1249, generalized in #1270).
INSERT INTO play_events (
user_id, track_id, session_id, started_at, client_id, source, pick_kind
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text)
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
device_class
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text,
sqlc.narg(device_class)::text)
RETURNING *;
-- name: GetSystemPickKindForTrack :one
+92 -3
View File
@@ -10,8 +10,10 @@ SELECT
(l.user_id IS NOT NULL)::bool AS is_liked,
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count
pe.skip_count,
al.release_date AS release_date
FROM tracks t
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT
@@ -40,7 +42,9 @@ WHERE t.id <> $2
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
-- positively-weighted taste-profile artists, so taste-relevant tracks
-- enter the pool even when the similarity/random arms miss them; scored
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
-- $11 coplay_artists K (#1533 — tracks by artists co-played across the
-- instance with the seed's artist; source='user_cooccurrence').
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
WITH
@@ -124,6 +128,22 @@ taste_overlap AS (
ORDER BY tpa.weight DESC, t.id
LIMIT $10
),
coplay_artists AS (
-- Household co-play (#1533): tracks by artists co-played across the
-- instance with the seed's artist (source='user_cooccurrence', built by
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
-- similar_artists since it's artist-level.
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
FROM artist_similarity asim
JOIN tracks t ON t.artist_id = asim.artist_b_id
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
WHERE asim.source = 'user_cooccurrence'
AND t.id NOT IN (SELECT id FROM excluded_ids)
AND t.id <> $2
ORDER BY asim.score DESC, random()
LIMIT $11
),
random_fill AS (
SELECT t.id AS track_id, 0.0::float8 AS sim_score
FROM tracks t
@@ -135,6 +155,7 @@ random_fill AS (
UNION SELECT track_id FROM tag_overlap
UNION SELECT track_id FROM likes_overlap
UNION SELECT track_id FROM taste_overlap
UNION SELECT track_id FROM coplay_artists
)
ORDER BY random()
LIMIT $9
@@ -145,6 +166,7 @@ SELECT
pe.last_played_at::timestamptz AS last_played_at,
pe.play_count,
pe.skip_count,
al.release_date AS release_date,
COALESCE(max(u.sim_score), 0.0) AS similarity_score
FROM (
SELECT track_id, sim_score FROM lb_similar
@@ -152,9 +174,11 @@ FROM (
UNION ALL SELECT track_id, sim_score FROM tag_overlap
UNION ALL SELECT track_id, sim_score FROM likes_overlap
UNION ALL SELECT track_id, sim_score FROM taste_overlap
UNION ALL SELECT track_id, sim_score FROM coplay_artists
UNION ALL SELECT track_id, sim_score FROM random_fill
) u
JOIN tracks t ON t.id = u.track_id
JOIN albums al ON al.id = t.album_id
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
LEFT JOIN LATERAL (
SELECT max(started_at) AS last_played_at,
@@ -166,7 +190,72 @@ LEFT JOIN LATERAL (
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
t.mbid, t.genre, t.added_at, t.updated_at,
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count;
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
al.release_date;
-- name: ListArtistContextPlayCountsForUser :many
-- Per-artist completed-play counts split by whether each play falls in the
-- CURRENT context cell, in the user's local timezone (#1531). The cell is
-- daypart × weekday-type, optionally narrowed by device class (#1551): when
-- $2 (the current device) is '', the device dimension is ignored (identical to
-- the time-only #1531 behaviour, used by the daily mixes which have no device);
-- when $2 is set (radio), a play only counts in the cell if its device_class
-- matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
-- window bounds cost. Daypart buckets: night [22,5) morning [5,12)
-- afternoon [12,17) evening [17,22). Weekend = ISO days 67 (Sat/Sun).
WITH tz AS (
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
FROM users u WHERE u.id = $1
),
now_cell AS (
SELECT
CASE
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM tz
),
plays AS (
SELECT t.artist_id,
pe.device_class,
CASE
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
ELSE 3
END AS daypart,
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
CROSS JOIN tz
WHERE pe.user_id = $1
AND pe.was_skipped = false
AND pe.started_at > now() - interval '365 days'
)
SELECT p.artist_id,
count(*) AS total_plays,
count(*) FILTER (
WHERE p.daypart = (SELECT daypart FROM now_cell)
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
AND ($2::text = '' OR p.device_class = $2::text)
) AS cell_plays
FROM plays p
GROUP BY p.artist_id;
-- name: GetLatestPlayDeviceClassForUser :one
-- The device_class of the user's most recent play (#1551), used as the
-- "current device" for radio context conditioning. NULL when the latest play
-- predates device capture or came from a client that didn't send one.
SELECT device_class
FROM play_events
WHERE user_id = $1
ORDER BY started_at DESC
LIMIT 1;
-- name: SuggestArtistsForUser :many
-- M5c: per-user artist suggestions ranked by signal x similarity. The
+19 -13
View File
@@ -6,8 +6,9 @@
-- doesn't exist yet. Never overwrites operator-tuned values.
INSERT INTO recommendation_weight_profiles (
profile, base_weight, like_boost, recency_weight, skip_penalty,
jitter_magnitude, context_weight, similarity_weight, taste_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
jitter_magnitude, context_weight, similarity_weight, taste_weight,
context_time_weight
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (profile) DO NOTHING;
-- name: ListWeightProfiles :many
@@ -15,23 +16,25 @@ SELECT * FROM recommendation_weight_profiles ORDER BY profile;
-- name: UpdateWeightProfile :one
UPDATE recommendation_weight_profiles
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
updated_at = now()
SET base_weight = $2,
like_boost = $3,
recency_weight = $4,
skip_penalty = $5,
jitter_magnitude = $6,
context_weight = $7,
similarity_weight = $8,
taste_weight = $9,
context_time_weight = $10,
updated_at = now()
WHERE profile = $1
RETURNING *;
-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip,
engagement_neutral, engagement_full
) VALUES (true, $1, $2, $3, $4)
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
mood_scale
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (singleton) DO NOTHING;
-- name: GetTasteTuning :one
@@ -43,6 +46,9 @@ UPDATE taste_tuning
engagement_hard_skip = $2,
engagement_neutral = $3,
engagement_full = $4,
enriched_tag_scale = $5,
era_scale = $6,
mood_scale = $7,
updated_at = now()
WHERE singleton = true
RETURNING *;
+5 -4
View File
@@ -47,7 +47,7 @@ UPDATE system_playlist_runs
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
-- name: PickSeedArtists :many
-- Top-5 most-engaged distinct artist candidates, tiered so the
-- Top-12 most-engaged distinct artist candidates, tiered so the
-- "Songs like X" mixes never silently vanish (#1255): the old hard
-- 7-day window emptied the seed pool after a quiet week, and the
-- daily atomic-replace build then deleted every existing mix until
@@ -61,8 +61,9 @@ UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
-- 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 5
-- daily-deterministically and takes 3 so the mix set rotates.
-- 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
),
@@ -123,7 +124,7 @@ SELECT c.artist_id,
FROM chosen c
LEFT JOIN liked l ON l.artist_id = c.artist_id
ORDER BY score DESC, c.artist_id
LIMIT 5;
LIMIT 12;
-- name: PickTopPlayedTracksForUser :many
-- For-You candidate seeds, tiered so For-You never silently vanishes:
+56
View File
@@ -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;
+40 -4
View File
@@ -6,10 +6,13 @@
-- One row per play in the decay-relevant window. completion is the play's
-- completion ratio (precomputed column when present, else duration_played /
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
-- is split into tags in Go. Quarantined tracks are excluded.
-- is split into tags in Go; release_date derives the decade for the era
-- facet (#1530). Quarantined tracks are excluded.
SELECT
t.id AS track_id,
t.artist_id,
t.genre,
a.release_date,
LEAST(GREATEST(
COALESCE(pe.completion_ratio,
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
@@ -17,6 +20,7 @@ SELECT
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
JOIN albums a ON a.id = t.album_id
WHERE pe.user_id = $1
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
AND NOT EXISTS (
@@ -25,11 +29,13 @@ WHERE pe.user_id = $1
);
-- name: ListLikedTrackTasteInputsForUser :many
-- (artist_id, genre) for each track the user has explicitly liked. Feeds the
-- track-like bonus into the liked track's artist and tags.
SELECT t.artist_id, t.genre
-- (track_id, artist_id, genre, release_date) for each track the user has
-- explicitly liked. Feeds the track-like bonus into the liked track's artist,
-- tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
JOIN albums a ON a.id = t.album_id
WHERE gl.user_id = $1;
-- name: ListLikedArtistIDsForUser :many
@@ -64,3 +70,33 @@ FROM taste_profile_tags
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2;
-- name: DeleteTasteProfileErasForUser :exec
DELETE FROM taste_profile_eras WHERE user_id = $1;
-- name: InsertTasteProfileEra :exec
INSERT INTO taste_profile_eras (user_id, era, weight)
VALUES ($1, $2, $3);
-- name: ListTasteProfileErasForUser :many
-- Top-weighted taste eras (#1530); consumed by the scorer's era term.
SELECT era, weight
FROM taste_profile_eras
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2;
-- name: DeleteTasteProfileMoodsForUser :exec
DELETE FROM taste_profile_moods WHERE user_id = $1;
-- name: InsertTasteProfileMood :exec
INSERT INTO taste_profile_moods (user_id, mood, weight)
VALUES ($1, $2, $3);
-- name: ListTasteProfileMoodsForUser :many
-- Top-weighted taste moods (#1534); consumed by the scorer's mood term.
SELECT mood, weight
FROM taste_profile_moods
WHERE user_id = $1
ORDER BY weight DESC
LIMIT $2;
+66
View File
@@ -0,0 +1,66 @@
-- 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, title + artist name for name-based fallback, and the
-- artist MBID (nullable) for MusicBrainz's artist-tag fallback (#1519).
-- $1 = current tag_sources_version, $2 = limit.
SELECT t.id, t.mbid, t.title, a.name AS artist_name, a.mbid AS artist_mbid
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;
-- name: ListTrackTagsForTracks :many
-- Enriched tags for a set of candidate tracks (#1534), so the scorer can
-- derive each candidate's mood buckets at scoring time. One row per (track,
-- tag); the caller filters to mood words via the internal/mood vocabulary.
SELECT tt.track_id, tt.tag
FROM track_tags tt
WHERE tt.track_id = ANY($1::uuid[]);
+12
View File
@@ -62,6 +62,10 @@ var dataTables = []string{
// (never recreates the singleton, seeded once by 0018); ResetDB
// resets its counter via UPDATE below instead.
"cover_art_provider_settings",
// Same reasoning for the tag-enrichment settings (#1490): truncate the
// per-provider rows (reconcile re-seeds registered ones), and reset the
// tag_sources_meta counter via UPDATE below rather than truncating it.
"tag_provider_settings",
// recsettings.New reconciles shipped defaults on every construction
// (#1250), so truncating gives each test pristine tuning values.
"recommendation_weight_profiles",
@@ -98,4 +102,12 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset cover-art version: %v", err)
}
// Same for the tag-sources counter (#1490) — clears cross-test version
// accumulation that would spuriously report version_bumped on a
// key-only change.
if _, err := pool.Exec(ctx,
"UPDATE tag_sources_meta SET current_version = 1, last_registered_providers_hash = ''",
); err != nil {
t.Fatalf("dbtest.ResetDB reset tag-sources version: %v", err)
}
}
+58
View File
@@ -0,0 +1,58 @@
// Package mood maps folksonomy tags (from the track_tags cache, #1490) to a
// small set of canonical mood buckets (#1534, milestone #160 Opt 2b). Both the
// taste-profile builder and the recommendation scorer classify a track's tags
// through Of() so a track's mood is derived the same way on both sides.
//
// The vocabulary is intentionally narrow: only words that clearly denote mood
// (not genre). A track with no mood-word tags contributes nothing — the mood
// facet is a supplement whose coverage grows with folksonomy enrichment
// (thin on MusicBrainz-only, richer once Last.fm is keyed).
package mood
import "strings"
// vocab maps a lowercased folksonomy tag to its canonical mood bucket. Several
// synonyms collapse to one bucket so "sad" / "melancholy" / "melancholic" all
// score the same dimension.
var vocab = map[string]string{
// melancholic
"melancholic": "melancholic", "melancholy": "melancholic", "sad": "melancholic",
"sombre": "melancholic", "somber": "melancholic", "wistful": "melancholic",
"bittersweet": "melancholic", "mournful": "melancholic",
// energetic
"energetic": "energetic", "energy": "energetic", "upbeat": "energetic",
"uplifting": "energetic", "party": "energetic", "anthemic": "energetic",
// chill
"chill": "chill", "chillout": "chill", "chilled": "chill", "mellow": "chill",
"relaxing": "chill", "relaxed": "chill", "calm": "chill", "laid-back": "chill",
"laidback": "chill", "soothing": "chill",
// aggressive
"aggressive": "aggressive", "angry": "aggressive", "intense": "aggressive",
"heavy": "aggressive", "brutal": "aggressive",
// dark
"dark": "dark", "moody": "dark", "brooding": "dark", "haunting": "dark", "eerie": "dark",
// dreamy
"dreamy": "dreamy", "ethereal": "dreamy", "atmospheric": "dreamy",
"ambient": "dreamy", "hypnotic": "dreamy",
// happy
"happy": "happy", "cheerful": "happy", "feel good": "happy", "feel-good": "happy",
"feelgood": "happy", "joyful": "happy", "fun": "happy",
// romantic
"romantic": "romantic", "sensual": "romantic", "sexy": "romantic", "sentimental": "romantic",
}
// Of returns the distinct canonical mood buckets present in tags, lowercasing +
// trimming each tag before lookup. Returns nil when no tag maps to a mood, so
// callers get no mood signal for a track with only genre/descriptive tags.
func Of(tags []string) []string {
var out []string
seen := make(map[string]bool)
for _, t := range tags {
key := strings.ToLower(strings.TrimSpace(t))
if m, ok := vocab[key]; ok && !seen[m] {
seen[m] = true
out = append(out, m)
}
}
return out
}
+43
View File
@@ -0,0 +1,43 @@
package mood
import (
"sort"
"testing"
)
func TestOf(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{"synonyms collapse", []string{"sad", "melancholy", "melancholic"}, []string{"melancholic"}},
{"case + whitespace", []string{" CHILL ", "Mellow"}, []string{"chill"}},
{"multiple buckets", []string{"energetic", "dark"}, []string{"dark", "energetic"}},
{"genre-only → none", []string{"rock", "shoegaze", "post-punk"}, nil},
{"empty", nil, nil},
}
for _, c := range cases {
got := Of(c.in)
sort.Strings(got)
want := append([]string(nil), c.want...)
sort.Strings(want)
if len(got) != len(want) {
t.Errorf("%s: Of(%v) = %v, want %v", c.name, c.in, got, want)
continue
}
for i := range got {
if got[i] != want[i] {
t.Errorf("%s: Of(%v) = %v, want %v", c.name, c.in, got, want)
break
}
}
}
}
func TestOf_Dedupes(t *testing.T) {
// Three tags mapping to the same bucket yield one entry.
if got := Of([]string{"chill", "mellow", "relaxed"}); len(got) != 1 || got[0] != "chill" {
t.Errorf("Of dedupe = %v, want [chill]", got)
}
}
+28 -17
View File
@@ -65,7 +65,16 @@ func (w *Writer) RecordPlayStarted(
clientID string,
at time.Time,
) (StartedResult, error) {
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", at)
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", "", at)
}
// strPtrOrNil maps an empty string to a NULL text column (nullable pointer),
// mirroring the clientID/source nil-on-empty pattern used above.
func strPtrOrNil(s string) *string {
if s == "" {
return nil
}
return &s
}
// systemPlaylistSources are the play_events.source values that count
@@ -136,7 +145,7 @@ func lookupSystemPickKind(
func (w *Writer) RecordPlayStartedWithSource(
ctx context.Context,
userID, trackID pgtype.UUID,
clientID, source string,
clientID, source, deviceClass string,
at time.Time,
) (StartedResult, error) {
var out StartedResult
@@ -165,13 +174,14 @@ func (w *Writer) RecordPlayStartedWithSource(
}
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
DeviceClass: strPtrOrNil(deviceClass),
})
if err != nil {
return err
@@ -389,7 +399,7 @@ func (w *Writer) RecordSyntheticCompletedPlay(
func (w *Writer) RecordOfflinePlay(
ctx context.Context,
userID, trackID pgtype.UUID,
clientID, source string,
clientID, source, deviceClass string,
at time.Time,
durationPlayedMs int32,
) error {
@@ -441,13 +451,14 @@ func (w *Writer) RecordOfflinePlay(
}
}
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
UserID: userID,
TrackID: trackID,
SessionID: sessionID,
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
ClientID: clientIDPtr,
Source: sourcePtr,
PickKind: pickKind,
DeviceClass: strPtrOrNil(deviceClass),
})
if err != nil {
return err
+9 -9
View File
@@ -226,10 +226,10 @@ func TestRecordOfflinePlay_DedupsByUserTrackStartedAt(t *testing.T) {
f := newFixture(t, 200_000)
ctx := context.Background()
at := time.Now().UTC()
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", "", at, 120_000); err != nil {
t.Fatalf("first offline: %v", err)
}
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", "", at, 120_000); err != nil {
t.Fatalf("replay offline: %v", err)
}
var count int
@@ -444,7 +444,7 @@ func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
f := newFixture(t, 200_000)
now := time.Now().UTC()
if _, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", now,
context.Background(), f.user, f.track, "c", "for_you", "", now,
); err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
}
@@ -460,7 +460,7 @@ func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
// Re-playing the same track stays a set (no duplicate append).
if _, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", now.Add(time.Minute),
context.Background(), f.user, f.track, "c", "for_you", "", now.Add(time.Minute),
); err != nil {
t.Fatalf("RecordPlayStartedWithSource 2: %v", err)
}
@@ -511,7 +511,7 @@ func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "for_you", "fresh")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
context.Background(), f.user, f.track, "c", "for_you", "", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
@@ -530,7 +530,7 @@ func TestRecordPlayStartedWithSource_NoSnapshotMatch_PickKindNull(t *testing.T)
// out, or no snapshot exists) stays unattributed rather than guessing.
f := newFixture(t, 200_000)
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
context.Background(), f.user, f.track, "c", "for_you", "", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
@@ -552,7 +552,7 @@ func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "for_you", "taste")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
context.Background(), f.user, f.track, "c", "discover", "", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
@@ -572,7 +572,7 @@ func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
f := newFixture(t, 200_000)
seedSystemSnapshot(t, f, "discover", "dormant")
res, err := f.w.RecordPlayStartedWithSource(
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
context.Background(), f.user, f.track, "c", "discover", "", time.Now().UTC(),
)
if err != nil {
t.Fatalf("RecordPlayStartedWithSource: %v", err)
@@ -593,7 +593,7 @@ func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
seedSystemSnapshot(t, f, "for_you", "taste")
at := time.Now().UTC().Add(-time.Hour)
if err := f.w.RecordOfflinePlay(
context.Background(), f.user, f.track, "c", "for_you", at, 180_000,
context.Background(), f.user, f.track, "c", "for_you", "", at, 180_000,
); err != nil {
t.Fatalf("RecordOfflinePlay: %v", err)
}
+85 -39
View File
@@ -10,10 +10,17 @@ import (
)
const (
discoverTotalSlots = 100
discoverDormantSlots = 40
discoverCrossUserSlots = 30
discoverRandomSlots = 30
discoverTotalSlots = 100
// Bucket allocation, pushed toward *targeted* novelty (Scribe #1488):
// the taste-matched-unheard arm gets the plurality, dormant stays a
// 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
discoverMaxTracksPerArtist = 3
)
@@ -21,9 +28,10 @@ const (
// discoverTrack is the common shape used by the bucket allocator. The
// three sqlc-generated row types collapse into this internal struct so
// downstream functions don't need to be generic over them. PickKind is
// the originating bucket (dormant/cross_user/random, #1270), stamped in
// the row adapters so it survives the interleave — bucket identity is
// what makes the 40/30/30 allocation measurable in the metrics.
// 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 {
ID pgtype.UUID
AlbumID pgtype.UUID
@@ -32,8 +40,9 @@ type discoverTrack struct {
}
// buildDiscoverCandidates assembles the Discover playlist track list.
// Pulls from three buckets, applies per-album/per-artist caps, then
// redistributes any deficit equally across the remaining buckets.
// Pulls from four buckets (taste_unheard / dormant / cross_user /
// 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
// appear in the playlist (round-robin interleaved across buckets).
@@ -43,6 +52,57 @@ type discoverTrack struct {
// the redistribution algorithm rolls the deficit into the surviving
// 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) {
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{
UserID: userID, Column2: dateStr,
})
@@ -67,37 +127,23 @@ func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.L
"user_id", uuidStringPL(userID), "err", err)
randomRows = nil
}
// Adapt sqlc-generated rows into the internal struct, then apply
// per-album / per-artist caps.
dormantPool := capByAlbumAndArtist(dormantRowsToTracks(dormantRows))
crossUserPool := capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows))
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, PickKind: t.PickKind})
return discoverPools{
taste: capByAlbumAndArtist(tasteUnheardRowsToTracks(tasteRows)),
dormant: capByAlbumAndArtist(dormantRowsToTracks(dormantRows)),
crossUser: capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows)),
random: capByAlbumAndArtist(randomRowsToTracks(randomRows)),
}
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 {
+39 -41
View File
@@ -1,6 +1,7 @@
package playlists
import (
"sort"
"testing"
"github.com/jackc/pgx/v5/pgtype"
@@ -98,8 +99,8 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
// pickDailySeeds shuffles the candidate pool daily-deterministically
// and takes up to n — For-You uses n=forYouSeedCount for its
// multi-seed blend (#1269), Songs-like uses n=3 via the
// pickSeedArtistsForDay wrapper. Verifies determinism within a day,
// multi-seed blend (#1269), Songs-like uses n=songsLikeSeedCount via
// the pickSeedArtistsForDay wrapper. Verifies determinism within a day,
// variation across days, and graceful degradation on small pools.
func TestPickDailySeeds_DeterministicWithinDay(t *testing.T) {
@@ -158,24 +159,38 @@ func TestPickDailySeeds_EmptyPool(t *testing.T) {
}
}
// 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.
// 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 := []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},
}
pool := bigSeedPool(10)
a := pickSeedArtistsForDay(pool, u, "2026-05-04")
b := pickSeedArtistsForDay(pool, u, "2026-05-04")
if len(a) != 3 || len(b) != 3 {
t.Fatalf("expected 3 seeds; got %d / %d", len(a), len(b))
if len(a) != songsLikeSeedCount || len(b) != songsLikeSeedCount {
t.Fatalf("expected %d seeds; got %d / %d", songsLikeSeedCount, len(a), len(b))
}
for i := range a {
if a[i] != b[i] {
@@ -186,40 +201,23 @@ func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
func TestPickSeedArtistsForDay_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},
}
// 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{}
pool := bigSeedPool(10)
// Collect the seed set (order-independent) across many dates. With
// C(10, songsLikeSeedCount) combinations, 30 dates yield >=2 distinct sets.
seen := map[string]bool{}
for i := 1; i <= 30; i++ {
got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i))
if len(got) != 3 {
t.Fatalf("expected 3 seeds; got %d", len(got))
if len(got) != songsLikeSeedCount {
t.Fatalf("expected %d seeds; got %d", songsLikeSeedCount, len(got))
}
// Sort the three byte values for set-equivalence comparison.
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
seen[seedSetKey(got)] = true
}
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}
pool := []pgtype.UUID{
{Bytes: [16]byte{10}, Valid: true},
+34 -17
View File
@@ -35,7 +35,7 @@ type seedArtistRow struct {
}
// 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
// call-site readable and makes the post-fetch path testable without
// a database.
@@ -110,10 +110,20 @@ func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n in
return shuffled[:n]
}
// pickSeedArtistsForDay picks up to 3 seed artists for the Songs-like
// mixes; For-You uses pickDailySeeds directly with forYouSeedCount.
// 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, 3)
return pickDailySeeds(pool, userID, dateStr, songsLikeSeedCount)
}
// rankedCandidate is a (track_id, score) pair used during in-memory
@@ -130,17 +140,18 @@ type rankedCandidate struct {
// 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 0039; the tier1-3
// values there land with the tiered-mix rebuilds.
// (#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"
pickKindCrossUser = "cross_user"
pickKindRandom = "random"
pickKindTier1 = "tier1"
pickKindTier2 = "tier2"
pickKindTier3 = "tier3"
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
@@ -204,6 +215,9 @@ var (
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
// while passive avoidance (negative) gently demotes.
TasteWeight: 1.5,
// Time-of-day/weekday context affinity (#1531), in [-1,+1]. Starts
// uniform with radio pending trend data.
ContextTimeWeight: 1.0,
}
systemTasteConfig = taste.DefaultConfig()
)
@@ -532,6 +546,9 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
// deep slice of the user's top taste-profile artists into the pool
// (#796 phase 2b). Empty for cold-start users (no profile yet).
TasteOverlap: 80,
// Household co-play (#1533): a deeper collaborative slice for the
// taste surfaces. Empty on single-user servers.
UserCoplay: 40,
}
}
@@ -588,8 +605,8 @@ func produceForYou(
return []builtPlaylist{{Name: "For You", Variant: "for_you", Tracks: tracks}}, nil
}
// produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed
// artists rotate daily-deterministically; the seed query falls back
// produceSeedMixes: up to songsLikeSeedCount "Songs like {artist}"
// mixes. Seed artists rotate daily-deterministically; the seed query falls back
// 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
@@ -684,7 +701,7 @@ func produceDiscover(
}
// 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
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
//
+61 -2
View File
@@ -8,6 +8,7 @@ import (
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
)
// LoadCandidates fetches the candidate pool for radio scoring. Combines
@@ -41,6 +42,11 @@ func LoadCandidates(
return nil, err
}
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
if err != nil {
return nil, err
}
out := make([]Candidate, 0, len(rows))
for _, r := range rows {
var lpt *time.Time
@@ -57,7 +63,11 @@ func LoadCandidates(
PlayCount: int(r.PlayCount),
SkipCount: int(r.SkipCount),
ContextualMatchScore: ctxScore,
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
// Fallback path: mood is scored only in the primary
// (similarity) loader — loading per-candidate tags over this
// near-whole-library pool isn't worth it (nil moods → 0).
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, nil),
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
},
})
}
@@ -76,6 +86,10 @@ type CandidateSourceLimits struct {
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
// users have an empty profile, so it contributes nothing anyway).
TasteOverlap int
// UserCoplay (#1533): tracks by artists co-played across the instance
// with the seed's artist (source='user_cooccurrence'). Empty on
// single-user servers, so it contributes nothing there.
UserCoplay int
}
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
@@ -87,6 +101,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
LikesOverlap: 20,
RandomFill: 30,
TasteOverlap: 20,
UserCoplay: 20,
}
}
@@ -119,6 +134,7 @@ func LoadCandidatesFromSimilarity(
Limit_4: int32(limits.LikesOverlap),
Limit_5: int32(limits.RandomFill),
Limit_6: int32(limits.TasteOverlap),
Limit_7: int32(limits.UserCoplay),
})
if err != nil {
return nil, err
@@ -134,6 +150,20 @@ func LoadCandidatesFromSimilarity(
return nil, err
}
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
if err != nil {
return nil, err
}
trackIDs := make([]pgtype.UUID, 0, len(rows))
for _, r := range rows {
trackIDs = append(trackIDs, r.Track.ID)
}
moods, err := loadCandidateMoods(ctx, q, trackIDs)
if err != nil {
return nil, err
}
out := make([]Candidate, 0, len(rows))
for _, r := range rows {
var lpt *time.Time
@@ -158,13 +188,42 @@ func LoadCandidatesFromSimilarity(
SkipCount: int(r.SkipCount),
ContextualMatchScore: ctxScore,
SimilarityScore: simScore,
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
TasteMatchScore: profile.Match(
r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, moods[r.Track.ID]),
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
},
})
}
return out, nil
}
// loadCandidateMoods fetches the enriched tags for the given candidate tracks
// and reduces each to its canonical mood buckets (internal/mood, #1534), so the
// scorer can apply the mood facet per candidate. Tracks with no mood-word tags
// are absent from the map (→ no mood signal). Empty input short-circuits.
func loadCandidateMoods(
ctx context.Context, q *dbq.Queries, trackIDs []pgtype.UUID,
) (map[pgtype.UUID][]string, error) {
if len(trackIDs) == 0 {
return map[pgtype.UUID][]string{}, nil
}
rows, err := q.ListTrackTagsForTracks(ctx, trackIDs)
if err != nil {
return nil, err
}
tagsByTrack := make(map[pgtype.UUID][]string)
for _, r := range rows {
tagsByTrack[r.TrackID] = append(tagsByTrack[r.TrackID], r.Tag)
}
out := make(map[pgtype.UUID][]string, len(tagsByTrack))
for id, tags := range tagsByTrack {
if m := mood.Of(tags); len(m) > 0 {
out[id] = m
}
}
return out, nil
}
// loadContextualLikesByTrack fetches the user's active contextual_likes in
// one query and groups them by track_id. Rows whose session_vector fails
// to unmarshal are skipped with no error (don't poison scoring over one
+75
View File
@@ -0,0 +1,75 @@
package recommendation
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// contextAffinityShrinkage is the pseudo-count that pulls a low-play artist's
// cell-share toward the user's baseline, so an artist with one or two plays
// can't swing its affinity to ±1 on noise. At k plays the estimate sits
// halfway between the raw cell-share and the baseline.
const contextAffinityShrinkage = 5.0
// ContextAffinity is the read-side map of per-artist time-of-day/weekday
// affinity for the CURRENT context (#1531): artist_id → score in [-1, +1].
// Absent artists (no play history) score 0, so cold-start candidates stay
// neutral. The zero value is a valid all-neutral affinity.
type ContextAffinity struct {
byArtist map[pgtype.UUID]float64
}
// Affinity returns the artist's current-context affinity, or 0 if unknown.
func (c ContextAffinity) Affinity(artistID pgtype.UUID) float64 {
return c.byArtist[artistID]
}
// LoadContextAffinity computes each artist's affinity for the user's CURRENT
// context cell — daypart × weekday, narrowed by deviceClass when non-empty
// (#1551; radio passes the current device, the daily mixes pass "" for a
// device-agnostic cell). For every artist with completed plays in the window it
// compares the share of that artist's plays that fall in the current cell
// against the user's overall baseline share, shrinking sparse artists toward
// the baseline. Returns an empty (all-neutral) affinity when the user has no
// plays.
func LoadContextAffinity(
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, deviceClass string,
) (ContextAffinity, error) {
rows, err := q.ListArtistContextPlayCountsForUser(ctx, dbq.ListArtistContextPlayCountsForUserParams{
ID: userID,
Column2: deviceClass,
})
if err != nil {
return ContextAffinity{}, err
}
var totalPlays, cellPlays int64
for _, r := range rows {
totalPlays += r.TotalPlays
cellPlays += r.CellPlays
}
out := ContextAffinity{byArtist: make(map[pgtype.UUID]float64, len(rows))}
if totalPlays == 0 {
return out, nil
}
baseline := float64(cellPlays) / float64(totalPlays)
for _, r := range rows {
out.byArtist[r.ArtistID] = contextAffinity(
float64(r.CellPlays), float64(r.TotalPlays), baseline, contextAffinityShrinkage)
}
return out, nil
}
// contextAffinity returns an artist's shrunk cell-share minus the user's
// baseline share, clamped to [-1, 1]. The shrinkage pseudo-count k pulls
// low-play artists toward the baseline (→ 0 affinity) so noise can't dominate;
// a heavily-played artist keeps close to its raw over/under-representation.
func contextAffinity(cellPlays, totalPlays, baseline, k float64) float64 {
if totalPlays == 0 {
return 0
}
shrunk := (cellPlays + baseline*k) / (totalPlays + k)
return clampUnit(shrunk - baseline)
}
@@ -0,0 +1,87 @@
package recommendation
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func insertPlayEventDevice(
t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, device string,
) {
t.Helper()
ctx := context.Background()
var sessionID pgtype.UUID
if err := pool.QueryRow(ctx,
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, $2, $2, 'ctx-dev-test') RETURNING id`,
userID, startedAt).Scan(&sessionID); err != nil {
t.Fatalf("insert play_session: %v", err)
}
if _, err := pool.Exec(ctx,
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped, device_class)
VALUES ($1, $2, $3, $4, false, $5)`,
userID, trackID, sessionID, startedAt, device); err != nil {
t.Fatalf("insert play_event: %v", err)
}
}
// TestLoadContextAffinity_DeviceNarrowing seeds one artist played only on
// mobile and another only on web (all in the current time cell), and verifies
// the current-device parameter narrows the affinity cell (#1551): on 'mobile'
// the mobile artist out-scores the web artist and vice-versa, while the
// device-agnostic (”) pass treats them equally.
func TestLoadContextAffinity_DeviceNarrowing(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
u := seedUser(t, pool, "ctx-dev")
aMobile := seedArtist(t, pool, "MobileArtist", "")
aWeb := seedArtist(t, pool, "WebArtist", "")
alM := seedAlbumForArtist(t, pool, aMobile.ID, "AlbM")
tM := seedTrackOnAlbum(t, pool, alM.ID, aMobile.ID, "TrkM")
alW := seedAlbumForArtist(t, pool, aWeb.ID, "AlbW")
tW := seedTrackOnAlbum(t, pool, alW.ID, aWeb.ID, "TrkW")
now := time.Now()
for i := 0; i < 5; i++ {
insertPlayEventDevice(t, pool, u.ID, tM.ID, now, "mobile")
insertPlayEventDevice(t, pool, u.ID, tW.ID, now, "web")
}
mob, err := LoadContextAffinity(ctx, q, u.ID, "mobile")
if err != nil {
t.Fatalf("mobile: %v", err)
}
if mob.Affinity(aMobile.ID) <= mob.Affinity(aWeb.ID) {
t.Errorf("on mobile, mobile artist (%.3f) should out-score web artist (%.3f)",
mob.Affinity(aMobile.ID), mob.Affinity(aWeb.ID))
}
web, err := LoadContextAffinity(ctx, q, u.ID, "web")
if err != nil {
t.Fatalf("web: %v", err)
}
if web.Affinity(aWeb.ID) <= web.Affinity(aMobile.ID) {
t.Errorf("on web, web artist (%.3f) should out-score mobile artist (%.3f)",
web.Affinity(aWeb.ID), web.Affinity(aMobile.ID))
}
// Device-agnostic ('' ): all plays fall in the same time cell, so the two
// artists are treated identically — the device dimension is what separates
// them above.
agn, err := LoadContextAffinity(ctx, q, u.ID, "")
if err != nil {
t.Fatalf("agnostic: %v", err)
}
if agn.Affinity(aMobile.ID) != agn.Affinity(aWeb.ID) {
t.Errorf("device-agnostic affinities should match, got mobile=%.3f web=%.3f",
agn.Affinity(aMobile.ID), agn.Affinity(aWeb.ID))
}
}
+57
View File
@@ -0,0 +1,57 @@
package recommendation
import (
"testing"
"time"
)
func TestContextAffinity(t *testing.T) {
const baseline = 0.4 // 40% of the user's plays fall in the current cell
const k = contextAffinityShrinkage
// Heavy history, over-represented in the current cell → positive.
if a := contextAffinity(80, 100, baseline, k); a <= 0 {
t.Errorf("over-represented artist affinity = %.3f, want positive", a)
}
// Heavy history, under-represented → negative.
if a := contextAffinity(10, 100, baseline, k); a >= 0 {
t.Errorf("under-represented artist affinity = %.3f, want negative", a)
}
// A sparse artist (1/1) shrinks toward the baseline, so its affinity is
// smaller than a heavily-played artist with the same raw cell-share.
sparse := contextAffinity(1, 1, baseline, k)
heavy := contextAffinity(100, 100, baseline, k)
if sparse >= heavy {
t.Errorf("sparse (%.3f) should shrink below heavy (%.3f)", sparse, heavy)
}
// No plays → neutral.
if a := contextAffinity(0, 0, baseline, k); a != 0 {
t.Errorf("no plays affinity = %.3f, want 0", a)
}
// Result stays within [-1, 1].
for _, tc := range [][2]float64{{100, 100}, {0, 100}, {50, 50}} {
a := contextAffinity(tc[0], tc[1], baseline, k)
if a < -1 || a > 1 {
t.Errorf("affinity out of [-1,1]: %.3f", a)
}
}
}
func TestScore_ContextTermAddsAndSubtracts(t *testing.T) {
now := time.Now()
zeroJitter := func() float64 { return 0.5 } // (0.5*2-1)=0 with any magnitude
w := ScoringWeights{ContextTimeWeight: 2.0} // all other weights 0
pos := Score(ScoringInputs{ContextAffinityScore: 1.0}, w, now, zeroJitter)
if !almostEq(pos, 2.0) {
t.Errorf("positive context affinity: Score = %.3f, want 2.0", pos)
}
neg := Score(ScoringInputs{ContextAffinityScore: -1.0}, w, now, zeroJitter)
if !almostEq(neg, -2.0) {
t.Errorf("negative context affinity: Score = %.3f, want -2.0 (demotes)", neg)
}
off := Score(ScoringInputs{ContextAffinityScore: 1.0}, ScoringWeights{}, now, zeroJitter)
if !almostEq(off, 0.0) {
t.Errorf("ContextTimeWeight 0: Score = %.3f, want 0 (no effect)", off)
}
}
+17 -8
View File
@@ -24,19 +24,26 @@ type ScoringInputs struct {
// user's taste, negative reflects passive avoidance, 0 when there's no
// profile signal (cold start / artist+tags absent from the profile).
TasteMatchScore float64
// ContextAffinityScore is the candidate artist's time-of-day/weekday
// affinity for the CURRENT context (#1531), in [-1, +1]: positive when the
// artist's plays concentrate in the current daypart × weekday-type cell
// more than the user's baseline, negative when under-represented, 0 when
// there's no history (cold-start neutral).
ContextAffinityScore float64
}
// ScoringWeights are the operator-tunable knobs. Defaults live in
// config.RecommendationConfig and are propagated here per request.
type ScoringWeights struct {
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
SimilarityWeight float64
TasteWeight float64
ContextTimeWeight float64
}
// Score computes the weighted-shuffle score per spec §6:
@@ -48,6 +55,7 @@ type ScoringWeights struct {
// + contextual_match_score * ContextWeight
// + similarity_score * SimilarityWeight
// + taste_match_score * TasteWeight
// + context_affinity_score * ContextTimeWeight
// + small_random_jitter
//
// Higher score = more likely to surface. rng is a function returning a
@@ -63,6 +71,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64
s += in.ContextualMatchScore * w.ContextWeight
s += in.SimilarityScore * w.SimilarityWeight
s += in.TasteMatchScore * w.TasteWeight
s += in.ContextAffinityScore * w.ContextTimeWeight
s += (rng()*2 - 1) * w.JitterMagnitude
return s
}
+5
View File
@@ -13,6 +13,11 @@ type SessionVector struct {
Artists []string `json:"artists"`
Tags map[string]int `json:"tags"`
RecentTrackIDs []string `json:"recent_track_ids"`
// DeviceClass is the current request's device (#1551), set by the radio
// handler from the user's latest play; drives the device dimension of the
// context-affinity term. Empty (omitted) for the daily mixes and for the
// snapshot stored on play_events, so it never narrows those.
DeviceClass string `json:"device_class,omitempty"`
}
func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
+70 -8
View File
@@ -2,6 +2,7 @@ package recommendation
import (
"context"
"fmt"
"math"
"github.com/jackc/pgx/v5/pgtype"
@@ -10,24 +11,37 @@ import (
)
// Taste-match tuning. The taste profile (written by internal/taste) holds
// signed, unbounded artist/tag weights; these scales squash them into a
// signed, unbounded artist/tag/era weights; these scales squash them into a
// bounded [-1, +1] match via tanh, so one outlier artist can't compress the
// rest toward zero (as a per-user max-normalisation would). A weight at the
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
//
// The era (#1530) and mood (#1534) terms are added on TOP of artist+tag
// (shares don't sum to 1) so they're pure nudges that never weaken the existing
// artist/tag signal when a track lacks a date or mood tags; clampUnit bounds
// the combined result. Their shares are small — both are coarse, partial-
// coverage signals — and re-bakeable if the lab shows they should carry more.
const (
tasteArtistScale = 4.0
tasteTagScale = 3.0
tasteEraScale = 4.0
tasteMoodScale = 3.0
tasteArtistShare = 0.7
tasteTagShare = 0.3
tasteEraShare = 0.15
tasteMoodShare = 0.12
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
)
// TasteProfile is the read-side view of a user's learned taste: signed
// weights over artists and genre tags. The zero value (and any unknown
// artist/tag) contributes 0, so cold-start users get no taste effect.
// TasteProfile is the read-side view of a user's learned taste: signed weights
// over artists, genre tags, decade/era buckets, and mood buckets. The zero
// value (and any unknown key) contributes 0, so cold-start users get no taste
// effect.
type TasteProfile struct {
artists map[pgtype.UUID]float64
tags map[string]float64
eras map[string]float64
moods map[string]float64
}
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
@@ -46,9 +60,23 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
if err != nil {
return TasteProfile{}, err
}
eras, err := q.ListTasteProfileErasForUser(ctx, dbq.ListTasteProfileErasForUserParams{
UserID: userID, Limit: tasteProfileLimit,
})
if err != nil {
return TasteProfile{}, err
}
moods, err := q.ListTasteProfileMoodsForUser(ctx, dbq.ListTasteProfileMoodsForUserParams{
UserID: userID, Limit: tasteProfileLimit,
})
if err != nil {
return TasteProfile{}, err
}
p := TasteProfile{
artists: make(map[pgtype.UUID]float64, len(arts)),
tags: make(map[string]float64, len(tags)),
eras: make(map[string]float64, len(eras)),
moods: make(map[string]float64, len(moods)),
}
for _, a := range arts {
p.artists[a.ArtistID] = a.Weight
@@ -56,13 +84,23 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
for _, t := range tags {
p.tags[t.Tag] = t.Weight
}
for _, e := range eras {
p.eras[e.Era] = e.Weight
}
for _, m := range moods {
p.moods[m.Mood] = m.Weight
}
return p, nil
}
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
// the artist's taste weight and the average of its genre tags' weights, each
// tanh-squashed. Absent artist/tags contribute 0.
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
// the artist's taste weight, the average of its genre tags' weights, its
// decade/era weight, and the average of its mood buckets' weights, each
// tanh-squashed. moods are the candidate's canonical mood buckets (from
// internal/mood; nil when unknown). Absent artist/tags/era/moods contribute 0.
func (p TasteProfile) Match(
artistID pgtype.UUID, genre *string, releaseDate pgtype.Date, moods []string,
) float64 {
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
var tg float64
@@ -76,7 +114,31 @@ func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
tg = math.Tanh((sum / float64(len(tags))) / tasteTagScale)
}
}
return clampUnit(tasteArtistShare*a + tasteTagShare*tg)
var er float64
if decade := decadeOf(releaseDate); decade != "" {
er = math.Tanh(p.eras[decade] / tasteEraScale)
}
var mo float64
if len(moods) > 0 {
var sum float64
for _, m := range moods {
sum += p.moods[m]
}
mo = math.Tanh((sum / float64(len(moods))) / tasteMoodScale)
}
return clampUnit(tasteArtistShare*a + tasteTagShare*tg + tasteEraShare*er + tasteMoodShare*mo)
}
// decadeOf maps an album release date to a decade bucket ("1990s"), or "" for
// an absent date. Mirrors the taste builder's helper (kept local to avoid a
// cross-package dependency) so a candidate's era is derived exactly as learned.
func decadeOf(d pgtype.Date) string {
if !d.Valid {
return ""
}
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
}
// clampUnit constrains x to [-1, 1].
+60 -8
View File
@@ -17,6 +17,14 @@ func uuidN(n byte) pgtype.UUID {
func strPtr(s string) *string { return &s }
// noDate is an absent release date — the era term contributes 0.
func noDate() pgtype.Date { return pgtype.Date{} }
// dateInYear builds a valid release date in the given year (era = its decade).
func dateInYear(year int) pgtype.Date {
return pgtype.Date{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true}
}
func TestTasteProfile_Match(t *testing.T) {
loved := uuidN(1)
disliked := uuidN(2)
@@ -26,32 +34,76 @@ func TestTasteProfile_Match(t *testing.T) {
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0},
}
if m := p.Match(loved, strPtr("Jazz")); m <= 0.5 {
if m := p.Match(loved, strPtr("Jazz"), noDate(), nil); m <= 0.5 {
t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m)
}
if m := p.Match(disliked, strPtr("Noise")); m >= -0.5 {
if m := p.Match(disliked, strPtr("Noise"), noDate(), nil); m >= -0.5 {
t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m)
}
if m := p.Match(unknown, nil); m != 0 {
if m := p.Match(unknown, nil, noDate(), nil); m != 0 {
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
}
// Artist dominates (0.7 share): loved artist with an unknown tag is still
// clearly positive.
if m := p.Match(loved, strPtr("Unheard")); m <= 0 {
if m := p.Match(loved, strPtr("Unheard"), noDate(), nil); m <= 0 {
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
}
// Output stays within [-1, 1] even with saturated inputs.
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
m := p.Match(a, strPtr("Jazz"))
m := p.Match(a, strPtr("Jazz"), dateInYear(1994), nil)
if m < -1 || m > 1 {
t.Errorf("Match out of [-1,1]: %.3f", m)
}
}
}
// TestTasteProfile_EraTerm verifies the decade facet nudges the match: with
// artist + genre held neutral, a loved era lifts the score and a disliked era
// lowers it, while an undated track is unaffected.
func TestTasteProfile_EraTerm(t *testing.T) {
art := uuidN(1)
p := TasteProfile{
artists: map[pgtype.UUID]float64{},
tags: map[string]float64{},
eras: map[string]float64{"1990s": 8.0, "1980s": -8.0},
}
loved := p.Match(art, nil, dateInYear(1994), nil)
if loved <= 0 {
t.Errorf("loved era (1990s) = %.3f, want positive", loved)
}
disliked := p.Match(art, nil, dateInYear(1987), nil)
if disliked >= 0 {
t.Errorf("disliked era (1980s) = %.3f, want negative", disliked)
}
if m := p.Match(art, nil, noDate(), nil); m != 0 {
t.Errorf("undated track = %.3f, want 0 (no era contribution)", m)
}
}
// TestTasteProfile_MoodTerm verifies the mood facet nudges the match: with
// artist/genre/era neutral, a loved mood lifts the score and a disliked mood
// lowers it, while a track with no mood buckets is unaffected.
func TestTasteProfile_MoodTerm(t *testing.T) {
art := uuidN(1)
p := TasteProfile{
artists: map[pgtype.UUID]float64{},
tags: map[string]float64{},
moods: map[string]float64{"chill": 8.0, "aggressive": -8.0},
}
if m := p.Match(art, nil, noDate(), []string{"chill"}); m <= 0 {
t.Errorf("loved mood (chill) = %.3f, want positive", m)
}
if m := p.Match(art, nil, noDate(), []string{"aggressive"}); m >= 0 {
t.Errorf("disliked mood (aggressive) = %.3f, want negative", m)
}
if m := p.Match(art, nil, noDate(), nil); m != 0 {
t.Errorf("no moods = %.3f, want 0 (no mood contribution)", m)
}
}
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
var p TasteProfile // zero value: nil maps
if m := p.Match(uuidN(1), strPtr("Jazz")); m != 0 {
if m := p.Match(uuidN(1), strPtr("Jazz"), dateInYear(1994), nil); m != 0 {
t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m)
}
}
@@ -100,10 +152,10 @@ func TestLoadTasteProfile_RoundTrip(t *testing.T) {
if err != nil {
t.Fatalf("load: %v", err)
}
if m := p.Match(art.ID, strPtr("Jazz")); m <= 0.5 {
if m := p.Match(art.ID, strPtr("Jazz"), noDate(), nil); m <= 0.5 {
t.Errorf("round-trip Match = %.3f, want strongly positive", m)
}
if m := p.Match(uuidN(9), nil); m != 0 {
if m := p.Match(uuidN(9), nil, noDate(), nil); m != 0 {
t.Errorf("absent artist Match = %.3f, want 0", m)
}
}
+13
View File
@@ -67,6 +67,10 @@ var weightFields = map[string]weightField{
get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v },
},
"context_time_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.ContextTimeWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.ContextTimeWeight = v },
},
}
// applyWeightPatch validates and applies a partial update, returning
@@ -129,6 +133,12 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
target = &next.EngagementNeutral
case "engagement_full":
target = &next.EngagementFull
case "enriched_tag_scale":
target = &next.EnrichedTagScale
case "era_scale":
target = &next.EraScale
case "mood_scale":
target = &next.MoodScale
default:
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
}
@@ -174,5 +184,8 @@ func diffTaste(a, b TasteTuning) []fieldChange {
add("engagement_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
add("engagement_full", a.EngagementFull, b.EngagementFull)
add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
add("era_scale", a.EraScale, b.EraScale)
add("mood_scale", a.MoodScale, b.MoodScale)
return out
}
+70 -43
View File
@@ -38,28 +38,36 @@ const (
)
// TasteTuning is the tunable subset of taste.Config: the engagement
// half-life and the completion→engagement curve points.
// half-life, the completion→engagement curve points, the enriched-tag
// weight (how much folksonomy tags count vs raw ID3 genre, #1520), and the
// era-facet weight (how strongly a decade-play imprints, #1530).
type TasteTuning struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale 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.
// ContextTimeWeight starts uniform (1.0) across both profiles pending
// trend data (#1531); split them once the metrics view justifies it.
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,
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
TasteWeight: 1.0,
ContextTimeWeight: 1.0,
}
}
@@ -67,14 +75,15 @@ func ShippedRadioWeights() recommendation.ScoringWeights {
// 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,
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
TasteWeight: 1.5,
ContextTimeWeight: 1.0,
}
}
@@ -86,6 +95,9 @@ func ShippedTasteTuning() TasteTuning {
EngagementHardSkip: d.Engagement.HardSkip,
EngagementNeutral: d.Engagement.NeutralCompletion,
EngagementFull: d.Engagement.FullCompletion,
EnrichedTagScale: d.EnrichedTagScale,
EraScale: d.EraScale,
MoodScale: d.MoodScale,
}
}
@@ -133,6 +145,9 @@ func (s *Service) reconcile(ctx context.Context) error {
EngagementHardSkip: st.EngagementHardSkip,
EngagementNeutral: st.EngagementNeutral,
EngagementFull: st.EngagementFull,
EnrichedTagScale: st.EnrichedTagScale,
EraScale: st.EraScale,
MoodScale: st.MoodScale,
}); err != nil {
return fmt.Errorf("seed taste tuning: %w", err)
}
@@ -156,6 +171,9 @@ func (s *Service) reconcile(ctx context.Context) error {
EngagementHardSkip: tt.EngagementHardSkip,
EngagementNeutral: tt.EngagementNeutral,
EngagementFull: tt.EngagementFull,
EnrichedTagScale: tt.EnrichedTagScale,
EraScale: tt.EraScale,
MoodScale: tt.MoodScale,
}
s.mu.Unlock()
@@ -205,6 +223,9 @@ func (s *Service) TasteConfig() taste.Config {
NeutralCompletion: t.EngagementNeutral,
FullCompletion: t.EngagementFull,
}
cfg.EnrichedTagScale = t.EnrichedTagScale
cfg.EraScale = t.EraScale
cfg.MoodScale = t.MoodScale
return cfg
}
@@ -301,6 +322,9 @@ func (s *Service) persistTaste(
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
MoodScale: t.MoodScale,
}); err != nil {
return fmt.Errorf("update taste tuning: %w", err)
}
@@ -334,41 +358,44 @@ func (s *Service) audit(
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,
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,
ContextTimeWeight: w.ContextTimeWeight,
}
}
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,
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,
ContextTimeWeight: w.ContextTimeWeight,
}
}
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,
BaseWeight: r.BaseWeight,
LikeBoost: r.LikeBoost,
RecencyWeight: r.RecencyWeight,
SkipPenalty: r.SkipPenalty,
JitterMagnitude: r.JitterMagnitude,
ContextWeight: r.ContextWeight,
SimilarityWeight: r.SimilarityWeight,
TasteWeight: r.TasteWeight,
ContextTimeWeight: r.ContextTimeWeight,
}
}
+61
View File
@@ -182,6 +182,67 @@ func TestUpdateTaste_CurveOrderingEnforced(t *testing.T) {
}
}
func TestUpdateTaste_EnrichedTagScale(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
// In-range update persists into cache + the assembled taste config.
if err := s.UpdateTaste(context.Background(),
map[string]float64{"enriched_tag_scale": 0.8}); err != nil {
t.Fatalf("UpdateTaste: %v", err)
}
if got := s.Taste().EnrichedTagScale; got != 0.8 {
t.Errorf("Taste().EnrichedTagScale = %v, want 0.8", got)
}
if got := s.TasteConfig().EnrichedTagScale; got != 0.8 {
t.Errorf("TasteConfig().EnrichedTagScale = %v, want 0.8", got)
}
// Out of [0,1] rejects.
if err := s.UpdateTaste(context.Background(),
map[string]float64{"enriched_tag_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
}
}
func TestUpdateTaste_EraScale(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
// In-range update persists into cache + the assembled taste config.
if err := s.UpdateTaste(context.Background(),
map[string]float64{"era_scale": 0.8}); err != nil {
t.Fatalf("UpdateTaste: %v", err)
}
if got := s.Taste().EraScale; got != 0.8 {
t.Errorf("Taste().EraScale = %v, want 0.8", got)
}
if got := s.TasteConfig().EraScale; got != 0.8 {
t.Errorf("TasteConfig().EraScale = %v, want 0.8", got)
}
// Out of [0,1] rejects.
if err := s.UpdateTaste(context.Background(),
map[string]float64{"era_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
}
}
func TestUpdateTaste_MoodScale(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
if err := s.UpdateTaste(context.Background(),
map[string]float64{"mood_scale": 0.8}); err != nil {
t.Fatalf("UpdateTaste: %v", err)
}
if got := s.Taste().MoodScale; got != 0.8 {
t.Errorf("Taste().MoodScale = %v, want 0.8", got)
}
if got := s.TasteConfig().MoodScale; got != 0.8 {
t.Errorf("TasteConfig().MoodScale = %v, want 0.8", got)
}
if err := s.UpdateTaste(context.Background(),
map[string]float64{"mood_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
}
}
func TestReset_RestoresShippedAndAudits(t *testing.T) {
pool := newPool(t)
s := newService(t, pool)
+3 -1
View File
@@ -29,6 +29,7 @@ import (
"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/tags"
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
"git.fabledsword.com/bvandeusen/minstrel/web"
)
@@ -77,6 +78,7 @@ type Server struct {
BrandingCfg config.BrandingConfig
CoverEnricher *coverart.Enricher
CoverSettings *coverart.SettingsService
TagSettings *tags.SettingsService
LibraryScanner *library.Scanner
ScanCfg library.RunScanConfig
// Bus is the live-event bus shared with background workers (the
@@ -162,7 +164,7 @@ func (s *Server) Router() http.Handler {
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.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret)
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
// (it needs the Scanner). Register it as a single inline-middleware
// route — using r.Route("/api/admin", ...) here would create a second
+261
View File
@@ -0,0 +1,261 @@
package tags
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// defaultTopK caps how many tags are cached per track. Enough for a rich
// facet, small enough that a heavily-tagged track can't swamp the taste
// profile when the recompute unions them in.
const defaultTopK = 8
// Source labels stamped onto tracks.tag_source. A single contributing
// provider stamps its own ID; two or more stamp "mixed". Kept in sync with
// the SetTrackTagSource query's documented whitelist.
const (
sourceNone = "none"
sourceMixed = "mixed"
)
// outcome classifies one track's enrichment for the batch tally.
type outcome int
const (
outcomeEnriched outcome = iota // wrote ≥1 tag
outcomeNone // every provider had nothing → settled 'none'
outcomeLeftNull // a transient failure → left NULL for retry
)
// Enricher drains tracks needing tags, fetches from the enabled provider
// chain, MERGES the results (union across providers, unlike coverart's
// first-success-wins for a single image), and caches the top-K into
// track_tags. Owns no pool; receives one via the constructor so the boot
// backfill, post-scan trigger, and admin endpoints share state.
type Enricher struct {
pool *pgxpool.Pool
logger *slog.Logger
settings *SettingsService
topK int
}
// NewEnricher constructs an Enricher.
func NewEnricher(pool *pgxpool.Pool, logger *slog.Logger, settings *SettingsService) *Enricher {
return &Enricher{pool: pool, logger: logger, settings: settings, topK: defaultTopK}
}
// EnrichTrack runs the chain for one track and persists the merged result.
//
// Every enabled TrackTagProvider is queried and its tags unioned (max
// weight wins on overlap). If any tags surface they're written with the
// source stamped by which providers contributed (musicbrainz / lastfm /
// mixed). If every provider cleanly reports ErrNotFound (or none are
// enabled) the row settles to 'none'. If a provider fails transiently and
// no tags surfaced, the row is left NULL for a next-pass retry.
func (e *Enricher) EnrichTrack(ctx context.Context, trackID pgtype.UUID, ref TrackRef) (outcome, error) {
merged := map[string]float64{}
contributors := map[string]bool{}
anyTransient := false
for _, provider := range e.settings.EnabledTrackTagProviders() {
tags, perr := provider.FetchTrackTags(ctx, ref)
switch {
case perr == nil:
for _, t := range tags {
if t.Weight > merged[t.Name] {
merged[t.Name] = t.Weight
}
}
if len(tags) > 0 {
contributors[provider.ID()] = true
}
case errors.Is(perr, ErrNotFound):
// Clean "no data from this source" — try the next provider.
default:
anyTransient = true
e.logger.Warn("tags: provider fetch failed; continuing",
"track_id", uuidString(trackID), "provider", provider.ID(), "err", perr)
}
}
if len(merged) > 0 {
top := topKByWeight(merged, e.topK)
source := sourceLabel(contributorIDs(contributors))
if err := e.writeTags(ctx, trackID, top, source, e.settings.CurrentVersion()); err != nil {
return outcomeLeftNull, err
}
return outcomeEnriched, nil
}
if anyTransient {
// Nothing landed but a source may recover — leave NULL for retry.
return outcomeLeftNull, nil
}
// Every provider (if any) had nothing: settle 'none' so we don't
// re-process until the version bumps (a provider enabled/added).
if err := e.writeTags(ctx, trackID, nil, sourceNone, e.settings.CurrentVersion()); err != nil {
return outcomeNone, err
}
return outcomeNone, nil
}
// writeTags atomically replaces a track's cached tags and stamps the source
// + version. tags may be empty (the 'none' settle path), which just clears
// any prior tags and records the outcome.
func (e *Enricher) writeTags(ctx context.Context, trackID pgtype.UUID, tags []Tag, source string, version int32) error {
tx, err := e.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
q := dbq.New(tx)
if err := q.DeleteTrackTags(ctx, trackID); err != nil {
return fmt.Errorf("delete track tags: %w", err)
}
for _, t := range tags {
if err := q.InsertTrackTag(ctx, dbq.InsertTrackTagParams{
TrackID: trackID, Tag: t.Name, Weight: t.Weight,
}); err != nil {
return fmt.Errorf("insert track tag: %w", err)
}
}
src := source
if err := q.SetTrackTagSource(ctx, dbq.SetTrackTagSourceParams{
ID: trackID, TagSource: &src, TagSourcesVersion: version,
}); err != nil {
return fmt.Errorf("set track tag source: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
// EnrichTrackBatch drains up to limit tracks needing tags and enriches each
// serially. limit: 0 = disabled; >0 = bounded; <0 = unbounded (remote
// providers self-throttle via their httpClient MinInterval). Returns the
// processed/succeeded/failed tally for the scan-run record.
func (e *Enricher) EnrichTrackBatch(ctx context.Context, limit int,
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
if limit == 0 {
return 0, 0, 0, nil
}
queryLimit := int32(limit)
if limit < 0 {
queryLimit = 1<<31 - 1
}
q := dbq.New(e.pool)
rows, qerr := q.ListTracksMissingTags(ctx, dbq.ListTracksMissingTagsParams{
TagSourcesVersion: e.settings.CurrentVersion(),
Limit: queryLimit,
})
if qerr != nil {
return 0, 0, 0, fmt.Errorf("list tracks missing tags: %w", qerr)
}
var enriched, settledNone, leftNull, errored int
for _, r := range rows {
if ctx.Err() != nil {
e.logBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
return processed, enriched, settledNone + leftNull + errored, ctx.Err()
}
processed++
ref := TrackRef{ArtistName: r.ArtistName, Title: r.Title}
if r.Mbid != nil {
ref.MBID = *r.Mbid
}
if r.ArtistMbid != nil {
ref.ArtistMBID = *r.ArtistMbid
}
oc, eerr := e.EnrichTrack(ctx, r.ID, ref)
if eerr != nil {
e.logger.Warn("tags: batch entry failed", "track_id", uuidString(r.ID), "err", eerr)
errored++
} else {
switch oc {
case outcomeEnriched:
enriched++
case outcomeNone:
settledNone++
case outcomeLeftNull:
leftNull++
}
}
if progressCb != nil {
progressCb(processed, enriched, settledNone+leftNull+errored)
}
}
e.logBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
return processed, enriched, settledNone + leftNull + errored, nil
}
// logBatchSummary emits one Info line with the category breakdown — the
// enriched/settled/left-null split is the operator's diagnostic for a
// "0 enriched" symptom the collapsed tally can't explain.
func (e *Enricher) logBatchSummary(eligible, processed, enriched, settledNone, leftNull, errored int) {
e.logger.Info("tags: enrichment batch complete",
"eligible", eligible,
"processed", processed,
"enriched", enriched,
"settled_none", settledNone,
"left_null", leftNull,
"errored", errored)
}
// topKByWeight returns the highest-weighted tags, tie-broken by name for
// determinism, capped at k (k<=0 means no cap).
func topKByWeight(merged map[string]float64, k int) []Tag {
tags := make([]Tag, 0, len(merged))
for name, w := range merged {
tags = append(tags, Tag{Name: name, Weight: w})
}
sort.Slice(tags, func(i, j int) bool {
if tags[i].Weight != tags[j].Weight {
return tags[i].Weight > tags[j].Weight
}
return tags[i].Name < tags[j].Name
})
if k > 0 && len(tags) > k {
tags = tags[:k]
}
return tags
}
// contributorIDs returns the sorted set of provider IDs that returned tags.
func contributorIDs(set map[string]bool) []string {
ids := make([]string, 0, len(set))
for id := range set {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
// sourceLabel maps the contributing providers onto the tag_source value:
// none / a single provider's own ID / "mixed" for two or more.
func sourceLabel(ids []string) string {
switch len(ids) {
case 0:
return sourceNone
case 1:
return ids[0]
default:
return sourceMixed
}
}
// uuidString renders a pgtype.UUID for logs.
func uuidString(u pgtype.UUID) string {
if !u.Valid {
return "<nil>"
}
return fmt.Sprintf("%x-%x-%x-%x-%x",
u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
}
+53
View File
@@ -0,0 +1,53 @@
package tags
import "testing"
func TestTopKByWeight(t *testing.T) {
merged := map[string]float64{
"a": 0.9,
"b": 0.5,
"c": 0.5, // ties with b → name asc breaks it (b before c)
"d": 0.1,
}
got := topKByWeight(merged, 3)
if len(got) != 3 {
t.Fatalf("cap not applied: got %d", len(got))
}
wantOrder := []string{"a", "b", "c"}
for i, w := range wantOrder {
if got[i].Name != w {
t.Errorf("position %d = %q, want %q (full: %v)", i, got[i].Name, w, got)
}
}
// k<=0 means no cap.
if len(topKByWeight(merged, 0)) != 4 {
t.Error("k=0 should not cap")
}
if len(topKByWeight(nil, 5)) != 0 {
t.Error("empty input yields empty output")
}
}
func TestSourceLabel(t *testing.T) {
cases := []struct {
ids []string
want string
}{
{nil, sourceNone},
{[]string{"musicbrainz"}, "musicbrainz"},
{[]string{"lastfm"}, "lastfm"},
{[]string{"lastfm", "musicbrainz"}, sourceMixed},
}
for _, c := range cases {
if got := sourceLabel(c.ids); got != c.want {
t.Errorf("sourceLabel(%v) = %q, want %q", c.ids, got, c.want)
}
}
}
func TestContributorIDs_Sorted(t *testing.T) {
got := contributorIDs(map[string]bool{"lastfm": true, "musicbrainz": true})
if len(got) != 2 || got[0] != "lastfm" || got[1] != "musicbrainz" {
t.Errorf("contributorIDs sorted = %v, want [lastfm musicbrainz]", got)
}
}
+184
View File
@@ -0,0 +1,184 @@
package tags
import (
"context"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"sync"
"time"
)
// httpClientOptions configures a httpClient. A trimmed sibling of the
// coverart HTTP helper — JSON-only (tag APIs return no binary), plus a
// User-Agent field because MusicBrainz rejects requests without one.
// Kept local to this package so tag enrichment never depends on
// coverart's internals.
type httpClientOptions struct {
HTTPClient *http.Client
// Name prefixes wrapped error messages (e.g. "tags lastfm: ...").
Name string
// UserAgent is sent on every request. MusicBrainz mandates a
// descriptive one; empty omits the header.
UserAgent string
// MinInterval throttles requests; 0 disables rate limiting.
MinInterval time.Duration
// MaxRetries is the number of retry attempts on 429 / 5xx.
MaxRetries int
// BaseBackoff is the base unit for exponential backoff (2^attempt × base ± 20%).
BaseBackoff time.Duration
// TreatAuthAsTransient maps 401/403 to ErrTransient (keyed providers
// want this so a bad/expired key retries rather than settling 'none').
TreatAuthAsTransient bool
// JSONBodyLimit caps the response body read.
JSONBodyLimit int64
}
// httpClient performs rate-limited, retrying JSON GETs. Each provider
// owns one, configured with provider-specific intervals + auth semantics.
type httpClient struct {
opts httpClientOptions
mu sync.Mutex
lastCall time.Time
}
func newHTTPClient(opts httpClientOptions) *httpClient {
if opts.HTTPClient == nil {
opts.HTTPClient = &http.Client{Timeout: 30 * time.Second}
}
if opts.MaxRetries == 0 {
opts.MaxRetries = 3
}
if opts.BaseBackoff == 0 {
opts.BaseBackoff = 250 * time.Millisecond
}
if opts.JSONBodyLimit == 0 {
opts.JSONBodyLimit = 1 * 1024 * 1024
}
return &httpClient{opts: opts}
}
// getJSON sends GET fullURL and decodes the 200 body into out (capped at
// JSONBodyLimit). Retries 429/5xx with exponential backoff up to
// MaxRetries. 404 → ErrNotFound; other non-2xx → wrapped ErrTransient.
func (c *httpClient) getJSON(ctx context.Context, fullURL string, out any) error {
for attempt := 0; attempt <= c.opts.MaxRetries; attempt++ {
if err := c.rateLimitWait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return fmt.Errorf("tags %s: build request: %w", c.opts.Name, err)
}
req.Header.Set("Accept", "application/json")
if c.opts.UserAgent != "" {
req.Header.Set("User-Agent", c.opts.UserAgent)
}
resp, err := c.opts.HTTPClient.Do(req)
if err != nil {
if attempt < c.opts.MaxRetries {
if waitErr := c.backoff(ctx, attempt); waitErr != nil {
return waitErr
}
continue
}
return fmt.Errorf("%w: %v", ErrTransient, err)
}
retry, ferr := c.handleStatus(ctx, resp, attempt)
if retry {
continue
}
if ferr != nil {
return ferr
}
body, rerr := io.ReadAll(io.LimitReader(resp.Body, c.opts.JSONBodyLimit))
_ = resp.Body.Close()
if rerr != nil {
return fmt.Errorf("%w: read body: %v", ErrTransient, rerr)
}
if uerr := json.Unmarshal(body, out); uerr != nil {
return fmt.Errorf("%w: decode body: %v", ErrTransient, uerr)
}
return nil
}
return fmt.Errorf("%w: exhausted retries", ErrTransient)
}
// handleStatus classifies a response. Returns retry=true to loop again
// (after backing off), or a terminal error, or (false, nil) to proceed to
// body read. Closes the body on every path except the proceed case.
func (c *httpClient) handleStatus(ctx context.Context, resp *http.Response, attempt int) (retry bool, err error) {
switch {
case resp.StatusCode == http.StatusNotFound:
_ = resp.Body.Close()
return false, ErrNotFound
case c.opts.TreatAuthAsTransient &&
(resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden):
_ = resp.Body.Close()
return false, fmt.Errorf("%w: status %d (auth)", ErrTransient, resp.StatusCode)
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
_ = resp.Body.Close()
if attempt < c.opts.MaxRetries {
if waitErr := c.backoff(ctx, attempt); waitErr != nil {
return false, waitErr
}
return true, nil
}
return false, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
case resp.StatusCode != http.StatusOK:
_ = resp.Body.Close()
return false, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
}
return false, nil
}
// rateLimitWait blocks until MinInterval has elapsed since the last call,
// then claims the slot. Disabled when MinInterval is 0.
func (c *httpClient) rateLimitWait(ctx context.Context) error {
if c.opts.MinInterval <= 0 {
return nil
}
c.mu.Lock()
wait := time.Until(c.lastCall.Add(c.opts.MinInterval))
if wait > 0 {
timer := time.NewTimer(wait)
c.mu.Unlock()
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
c.mu.Lock()
}
c.lastCall = time.Now()
c.mu.Unlock()
return nil
}
// backoff sleeps for 2^attempt × BaseBackoff ± 20% jitter, or until ctx is done.
func (c *httpClient) backoff(ctx context.Context, attempt int) error {
base := time.Duration(1<<attempt) * c.opts.BaseBackoff
jitterRange := int64(base) / 5
if jitterRange <= 0 {
jitterRange = 1
}
jitter := time.Duration(rand.Int63n(jitterRange))
if rand.Intn(2) == 0 {
jitter = -jitter
}
timer := time.NewTimer(base + jitter)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+161
View File
@@ -0,0 +1,161 @@
// Package tags enriches the taste profile's tag facet beyond raw ID3
// genre (milestone #160, task #1490). It fetches folksonomy style/mood
// tags for tracks from a chain of pluggable providers (MusicBrainz,
// Last.fm, …), caches them in track_tags, and the taste recompute unions
// them alongside tracks.genre.
//
// The provider model mirrors internal/coverart deliberately: a source is
// added by implementing TrackTagProvider and calling Register() in the
// provider file's init() — no enricher, settings, or schema change. The
// DB-backed SettingsService then auto-upserts a settings row for it, and
// the boot-time provider-hash bump makes previously-settled rows eligible
// for a retry through the widened chain.
package tags
import (
"context"
"errors"
"fmt"
"sync"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
)
// Tag is one folksonomy tag with a normalized strength in [0,1]. Each
// provider normalizes its own upstream weighting (Last.fm's 0-100 count,
// MusicBrainz's vote count) into this range so the enricher can merge
// across providers on a common scale.
type Tag struct {
Name string
Weight float64
}
// TrackRef is the lookup key passed to a provider. MBID is the track's
// recording MBID (preferred by MBID-keyed providers); ArtistName + Title
// are the always-populated fallback for name-based providers (Last.fm);
// ArtistMBID (nullable) enables MusicBrainz's artist-tag fallback (#1519)
// when the recording itself is untagged or MBID-less.
type TrackRef struct {
MBID string
ArtistName string
Title string
ArtistMBID string
}
// Provider is the base shape every tag source implements. Providers
// register at init() via Register(). The SettingsService reads the
// per-provider row (enabled, api_key) and calls Configure().
type Provider interface {
// ID is a stable lowercase token persisted in tracks.tag_source and
// keyed in tag_provider_settings.provider_id, and shown in the admin UI.
ID() string
// DisplayName is the human-readable label for the admin Settings card.
DisplayName() string
// RequiresAPIKey reports whether the provider needs a key to function.
// The admin UI shows/hides the key field on this; it does NOT gate the
// enabled toggle (rule #26 — providers with usable defaults ship working).
RequiresAPIKey() bool
// DefaultEnabled is whether a freshly-installed Minstrel ships with
// this provider on. MusicBrainz (keyless) returns true; Last.fm
// (keyed) returns false — opt-in once a key is supplied.
DefaultEnabled() bool
// Configure applies the operator's settings. Called at boot and on
// every admin PATCH; must be idempotent and goroutine-safe (the
// enricher may read concurrently).
Configure(ProviderSettings) error
}
// ProviderSettings is the runtime config a provider receives on Configure.
type ProviderSettings struct {
Enabled bool
APIKey string
}
// TrackTagProvider is the tag-fetch capability. Split from Provider so a
// future source with a different capability (e.g. artist-level tags) can
// be added without widening this one.
type TrackTagProvider interface {
Provider
// FetchTrackTags returns the folksonomy tags for the track, or
// ErrNotFound (terminal — nothing upstream) / ErrTransient (retry).
// A disabled provider, or one missing a required key, returns
// ErrNotFound so the chain simply skips it.
FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error)
}
// TestableProvider is an opt-in capability for the admin Test-Connection
// button: answer "is my config working?" without a full enrichment cycle.
type TestableProvider interface {
Provider
TestConnection(ctx context.Context) error
}
// ErrNotFound: the provider confirmed it has no tags for this track — a
// terminal-this-pass result. The enricher moves to the next provider; if
// every provider returns ErrNotFound the row settles to tag_source='none'.
// Aliased to apierror.ErrNotFound for consistency with the rest of the app.
var ErrNotFound = apierror.ErrNotFound
// ErrTransient: a temporary failure (5xx, network, timeout, auth). The
// enricher leaves the row's tag_source NULL so the next pass retries.
var ErrTransient = errors.New("tags: transient error")
// ErrProviderNotFound is returned when a named provider is not registered.
var ErrProviderNotFound = errors.New("tags: provider not registered")
// ErrNotTestable is returned when a provider doesn't implement
// TestableProvider.
var ErrNotTestable = errors.New("tags: provider does not support test connection")
// registry holds all compiled-in providers. Providers register at init()
// via Register(); the enricher iterates this list filtered by settings.
var registry = struct {
mu sync.Mutex
providers []Provider
}{}
// Register adds a provider. Must be called from a provider file's init().
// Duplicate IDs panic — providers are compiled-in, so it's a build bug.
func Register(p Provider) {
registry.mu.Lock()
defer registry.mu.Unlock()
for _, existing := range registry.providers {
if existing.ID() == p.ID() {
panic(fmt.Sprintf("tags: duplicate provider ID %q", p.ID()))
}
}
registry.providers = append(registry.providers, p)
}
// AllProviders returns a snapshot in registration order. Do not mutate.
func AllProviders() []Provider {
registry.mu.Lock()
defer registry.mu.Unlock()
out := make([]Provider, len(registry.providers))
copy(out, registry.providers)
return out
}
// ProviderByID looks up a registered provider. Returns ErrProviderNotFound
// if none matches.
func ProviderByID(id string) (Provider, error) {
registry.mu.Lock()
defer registry.mu.Unlock()
for _, p := range registry.providers {
if p.ID() == id {
return p, nil
}
}
return nil, ErrProviderNotFound
}
// ResetRegistryForTests clears the registry. Test-only.
func ResetRegistryForTests() {
registry.mu.Lock()
defer registry.mu.Unlock()
registry.providers = nil
}
+168
View File
@@ -0,0 +1,168 @@
package tags
import (
"context"
"errors"
"net/http"
"net/url"
"sync/atomic"
"time"
)
// lastfmBaseURL is a var (not const) so tests can override it.
var lastfmBaseURL = "https://ws.audioscrobbler.com/2.0/"
const (
// lastfmMinPeriod throttles to 4 req/s under the 5/s per-key ceiling.
lastfmMinPeriod = 250 * time.Millisecond
// lastfmTagMaxCount is Last.fm's top-tag count scale (0-100 popularity).
lastfmTagMaxCount = 100.0
)
// lastfmTransientErrors are Last.fm JSON-level error codes that mean
// "try again" rather than "no data": 8 operation failed, 11/16 service
// temporarily unavailable, 29 rate limit. Everything else non-zero
// (6 not found, 10 invalid key, …) is treated as ErrNotFound so the
// chain simply skips this provider for the track.
var lastfmTransientErrors = map[int]bool{8: true, 11: true, 16: true, 29: true}
// lastfmProvider fetches track-level folksonomy tags via Last.fm's
// track.getTopTags. Requires an operator-supplied API key; stays inert
// (ErrNotFound on every call) when disabled or unkeyed. Name-based, so it
// covers the many local tracks that lack a recording MBID.
type lastfmProvider struct {
enabled atomic.Bool
apiKey atomic.Pointer[string]
client *httpClient
}
func init() {
Register(&lastfmProvider{
client: newHTTPClient(httpClientOptions{
Name: "lastfm",
HTTPClient: &http.Client{Timeout: 30 * time.Second},
MinInterval: lastfmMinPeriod,
MaxRetries: 3,
TreatAuthAsTransient: true,
}),
})
}
func (p *lastfmProvider) ID() string { return "lastfm" }
func (p *lastfmProvider) DisplayName() string { return "Last.fm" }
func (p *lastfmProvider) RequiresAPIKey() bool { return true }
func (p *lastfmProvider) DefaultEnabled() bool { return false }
func (p *lastfmProvider) Configure(s ProviderSettings) error {
p.enabled.Store(s.Enabled)
key := s.APIKey
p.apiKey.Store(&key)
return nil
}
func (p *lastfmProvider) currentKey() string {
if k := p.apiKey.Load(); k != nil {
return *k
}
return ""
}
// lastfmTopTags models the subset of track.getTopTags we read. Last.fm
// returns `error` on failure responses instead of the toptags block.
type lastfmTopTags struct {
TopTags struct {
Tag []lastfmTag `json:"tag"`
} `json:"toptags"`
Error int `json:"error"`
}
type lastfmTag struct {
Name string `json:"name"`
Count int `json:"count"`
}
// FetchTrackTags looks up the track's top tags by artist + title (with the
// recording MBID as an extra hint when present). Returns ErrNotFound when
// disabled, unkeyed, missing artist/title, or when Last.fm has no tags.
func (p *lastfmProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error) {
if !p.enabled.Load() || p.currentKey() == "" {
return nil, ErrNotFound
}
if ref.ArtistName == "" || ref.Title == "" {
return nil, ErrNotFound
}
q := url.Values{
"method": {"track.gettoptags"},
"api_key": {p.currentKey()},
"format": {"json"},
"artist": {ref.ArtistName},
"track": {ref.Title},
"autocorrect": {"1"},
}
if ref.MBID != "" {
q.Set("mbid", ref.MBID)
}
var resp lastfmTopTags
if err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp); err != nil {
return nil, err
}
if resp.Error != 0 {
if lastfmTransientErrors[resp.Error] {
return nil, ErrTransient
}
return nil, ErrNotFound
}
out := normalizeLastfmTags(resp.TopTags.Tag)
if len(out) == 0 {
return nil, ErrNotFound
}
return out, nil
}
// TestConnection verifies the key against a well-known track.
func (p *lastfmProvider) TestConnection(ctx context.Context) error {
if p.currentKey() == "" {
return errors.New("tags: lastfm api key not set")
}
q := url.Values{
"method": {"track.gettoptags"},
"api_key": {p.currentKey()},
"format": {"json"},
"artist": {"Radiohead"},
"track": {"Creep"},
}
var resp lastfmTopTags
err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp)
if err != nil && !errors.Is(err, ErrNotFound) {
return err
}
if resp.Error == 10 { // invalid API key
return errors.New("tags: lastfm rejected the api key")
}
return nil
}
// normalizeLastfmTags scales the 0-100 popularity counts into [0,1] and
// drops zero-count / unnamed tags.
func normalizeLastfmTags(raw []lastfmTag) []Tag {
out := make([]Tag, 0, len(raw))
for _, t := range raw {
if t.Count <= 0 || t.Name == "" {
continue
}
w := float64(t.Count) / lastfmTagMaxCount
if w > 1 {
w = 1
}
out = append(out, Tag{Name: t.Name, Weight: w})
}
return out
}
// Compile-time capability checks.
var (
_ TrackTagProvider = (*lastfmProvider)(nil)
_ TestableProvider = (*lastfmProvider)(nil)
)
+101
View File
@@ -0,0 +1,101 @@
package tags
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)
func newLastfmProvider(enabled bool, key string) *lastfmProvider {
p := &lastfmProvider{client: newHTTPClient(httpClientOptions{
Name: "lastfm", MaxRetries: 1, TreatAuthAsTransient: true,
})}
_ = p.Configure(ProviderSettings{Enabled: enabled, APIKey: key})
return p
}
func TestNormalizeLastfmTags(t *testing.T) {
got := tagsByName(normalizeLastfmTags([]lastfmTag{
{Name: "indie", Count: 100},
{Name: "dream pop", Count: 40},
{Name: "zero-drop", Count: 0},
{Name: "", Count: 50},
{Name: "clamp", Count: 150}, // >100 clamps to 1.0
}))
if len(got) != 3 {
t.Fatalf("want 3 tags, got %v", got)
}
if got["indie"] != 1.0 || got["dream pop"] != 0.4 || got["clamp"] != 1.0 {
t.Errorf("weights = %v", got)
}
}
func TestLastfmFetch_GatedOff(t *testing.T) {
// Enabled but unkeyed → ErrNotFound.
if _, err := newLastfmProvider(true, "").FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B"}); !errors.Is(err, ErrNotFound) {
t.Errorf("unkeyed: err = %v, want ErrNotFound", err)
}
// Keyed but disabled → ErrNotFound.
if _, err := newLastfmProvider(false, "k").FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B"}); !errors.Is(err, ErrNotFound) {
t.Errorf("disabled: err = %v, want ErrNotFound", err)
}
// Keyed + enabled but missing artist/title → ErrNotFound.
if _, err := newLastfmProvider(true, "k").FetchTrackTags(context.Background(),
TrackRef{MBID: "x"}); !errors.Is(err, ErrNotFound) {
t.Errorf("no artist/title: err = %v, want ErrNotFound", err)
}
}
func TestLastfmFetch_ParsesTags(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"toptags":{"tag":[{"name":"shoegaze","count":100},{"name":"90s","count":30}]}}`))
}))
defer srv.Close()
old := lastfmBaseURL
lastfmBaseURL = srv.URL + "/"
defer func() { lastfmBaseURL = old }()
tags, err := newLastfmProvider(true, "k").FetchTrackTags(context.Background(),
TrackRef{ArtistName: "Slowdive", Title: "Alison"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
m := tagsByName(tags)
if m["shoegaze"] != 1.0 || m["90s"] != 0.3 {
t.Errorf("weights = %v", m)
}
}
func TestLastfmFetch_ErrorCodes(t *testing.T) {
cases := []struct {
name string
errorCode int
want error
}{
{"not-found", 6, ErrNotFound},
{"transient-rate-limit", 29, ErrTransient},
{"transient-unavailable", 16, ErrTransient},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"error":` + strconv.Itoa(c.errorCode) + `,"message":"x"}`))
}))
defer srv.Close()
old := lastfmBaseURL
lastfmBaseURL = srv.URL + "/"
defer func() { lastfmBaseURL = old }()
_, err := newLastfmProvider(true, "k").FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B"})
if !errors.Is(err, c.want) {
t.Errorf("error %d: got %v, want %v", c.errorCode, err, c.want)
}
})
}
}
+158
View File
@@ -0,0 +1,158 @@
package tags
import (
"context"
"errors"
"net/http"
"net/url"
"sync/atomic"
"time"
)
// mbBaseURL is a var (not const) so tests can point it at an httptest server.
var mbBaseURL = "https://musicbrainz.org/ws/2"
const (
// mbMinPeriod honors MusicBrainz's 1 req/s anonymous ceiling (with headroom).
mbMinPeriod = 1100 * time.Millisecond
// mbUserAgent identifies the app — MusicBrainz rejects requests without one.
mbUserAgent = "Minstrel/1.0 ( https://git.fabledsword.com/bvandeusen/minstrel )"
// artistTagWeightFactor down-weights artist-level fallback tags relative
// to recording-specific tags — the artist's overall character is a coarser
// signal than a tag on the exact recording (#1519).
artistTagWeightFactor = 0.6
)
// musicbrainzProvider fetches folksonomy tags from the MusicBrainz web
// service. Keyless (the public endpoint needs no auth) and on by default —
// the always-available baseline source. Prefers recording-level tags by the
// track's recording MBID; when the recording is untagged or MBID-less it
// falls back to the artist's tags by artist MBID (#1519), down-weighted as a
// coarser signal. Name-based lookup is deferred to providers like Last.fm.
type musicbrainzProvider struct {
enabled atomic.Bool
client *httpClient
}
func init() {
Register(&musicbrainzProvider{
client: newHTTPClient(httpClientOptions{
Name: "musicbrainz",
UserAgent: mbUserAgent,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
MinInterval: mbMinPeriod,
MaxRetries: 3,
}),
})
}
func (p *musicbrainzProvider) ID() string { return "musicbrainz" }
func (p *musicbrainzProvider) DisplayName() string { return "MusicBrainz" }
func (p *musicbrainzProvider) RequiresAPIKey() bool { return false }
func (p *musicbrainzProvider) DefaultEnabled() bool { return true }
func (p *musicbrainzProvider) Configure(s ProviderSettings) error {
p.enabled.Store(s.Enabled)
return nil
}
// mbTagsResponse models the subset of /ws/2/recording/{mbid}?inc=tags we read.
type mbTagsResponse struct {
Tags []mbTag `json:"tags"`
}
type mbTag struct {
Count int `json:"count"`
Name string `json:"name"`
}
// FetchTrackTags looks up recording tags by MBID, then falls back to artist
// tags by artist MBID when the recording is untagged or MBID-less. Returns
// ErrNotFound when disabled or when neither level yields tags. A transient
// error at the recording step is returned as-is (retry) rather than silently
// masked by the artist fallback.
func (p *musicbrainzProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error) {
if !p.enabled.Load() {
return nil, ErrNotFound
}
if ref.MBID != "" {
tags, err := p.fetchEntityTags(ctx, "recording", ref.MBID, 1.0)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if len(tags) > 0 {
return tags, nil
}
}
if ref.ArtistMBID != "" {
tags, err := p.fetchEntityTags(ctx, "artist", ref.ArtistMBID, artistTagWeightFactor)
if err != nil && !errors.Is(err, ErrNotFound) {
return nil, err
}
if len(tags) > 0 {
return tags, nil
}
}
return nil, ErrNotFound
}
// fetchEntityTags loads folksonomy tags for a MusicBrainz entity ("recording"
// or "artist") by MBID and scales the normalized weights by `scale`. Returns
// an empty slice (not ErrNotFound) when the entity exists but is untagged, so
// the caller can decide whether to fall through to the next level.
func (p *musicbrainzProvider) fetchEntityTags(ctx context.Context, entity, mbid string, scale float64) ([]Tag, error) {
q := url.Values{"inc": {"tags"}, "fmt": {"json"}}
full := mbBaseURL + "/" + entity + "/" + url.PathEscape(mbid) + "?" + q.Encode()
var resp mbTagsResponse
if err := p.client.getJSON(ctx, full, &resp); err != nil {
return nil, err
}
tags := normalizeMBTags(resp.Tags)
if scale != 1.0 {
for i := range tags {
tags[i].Weight *= scale
}
}
return tags, nil
}
// TestConnection issues a tiny search request; any non-transient response
// means MusicBrainz is reachable.
func (p *musicbrainzProvider) TestConnection(ctx context.Context) error {
full := mbBaseURL + "/recording?query=*&limit=1&fmt=json"
var discard map[string]any
err := p.client.getJSON(ctx, full, &discard)
if err == nil || errors.Is(err, ErrNotFound) {
return nil
}
return err
}
// normalizeMBTags drops non-positive-vote tags (net-downvoted or zero) and
// scales the remaining vote counts into [0,1] relative to the strongest tag
// on this recording, so one recording's raw counts don't dominate another's.
func normalizeMBTags(raw []mbTag) []Tag {
maxCount := 0
for _, t := range raw {
if t.Count > maxCount {
maxCount = t.Count
}
}
if maxCount == 0 {
return nil
}
out := make([]Tag, 0, len(raw))
for _, t := range raw {
if t.Count <= 0 || t.Name == "" {
continue
}
out = append(out, Tag{Name: t.Name, Weight: float64(t.Count) / float64(maxCount)})
}
return out
}
// Compile-time capability checks.
var (
_ TrackTagProvider = (*musicbrainzProvider)(nil)
_ TestableProvider = (*musicbrainzProvider)(nil)
)
+178
View File
@@ -0,0 +1,178 @@
package tags
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func newMBProvider(enabled bool) *musicbrainzProvider {
p := &musicbrainzProvider{client: newHTTPClient(httpClientOptions{Name: "musicbrainz", MaxRetries: 1})}
_ = p.Configure(ProviderSettings{Enabled: enabled})
return p
}
func tagsByName(ts []Tag) map[string]float64 {
m := make(map[string]float64, len(ts))
for _, t := range ts {
m[t.Name] = t.Weight
}
return m
}
func TestNormalizeMBTags(t *testing.T) {
got := tagsByName(normalizeMBTags([]mbTag{
{Count: 3, Name: "rock"},
{Count: 6, Name: "shoegaze"},
{Count: 0, Name: "zero-drop"},
{Count: -2, Name: "downvoted-drop"},
{Count: 5, Name: ""},
}))
if len(got) != 2 {
t.Fatalf("want 2 tags, got %v", got)
}
if got["shoegaze"] != 1.0 {
t.Errorf("shoegaze weight = %v, want 1.0", got["shoegaze"])
}
if got["rock"] != 0.5 {
t.Errorf("rock weight = %v, want 0.5", got["rock"])
}
if normalizeMBTags(nil) != nil {
t.Error("nil input should yield nil")
}
}
func TestMusicBrainzFetch_GatedOff(t *testing.T) {
// Disabled → ErrNotFound without any network call.
if _, err := newMBProvider(false).FetchTrackTags(context.Background(),
TrackRef{MBID: "abc"}); !errors.Is(err, ErrNotFound) {
t.Errorf("disabled: err = %v, want ErrNotFound", err)
}
// No recording MBID and no artist MBID → nothing to look up → ErrNotFound.
if _, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B"}); !errors.Is(err, ErrNotFound) {
t.Errorf("no MBID: err = %v, want ErrNotFound", err)
}
}
// mbEntityServer returns tags keyed by entity path segment ("recording" or
// "artist"), so a test can make the recording untagged while the artist has
// tags (or vice-versa). An empty body string means "{tags:[]}".
func mbEntityServer(recordingJSON, artistJSON string) *httptest.Server {
empty := `{"tags":[]}`
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body := empty
switch {
case strings.Contains(r.URL.Path, "/recording/"):
if recordingJSON != "" {
body = recordingJSON
}
case strings.Contains(r.URL.Path, "/artist/"):
if artistJSON != "" {
body = artistJSON
}
}
_, _ = w.Write([]byte(body))
}))
}
func TestMusicBrainzFetch_ArtistFallbackWhenRecordingUntagged(t *testing.T) {
// Recording exists but has no tags; artist has tags → use the artist's,
// down-weighted by artistTagWeightFactor.
srv := mbEntityServer(``, `{"tags":[{"count":4,"name":"art rock"},{"count":2,"name":"experimental"}]}`)
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
tags, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{MBID: "rec-1", ArtistMBID: "art-1"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
m := tagsByName(tags)
// "art rock" is the top artist tag (weight 1.0) × 0.6 fallback factor.
if got := m["art rock"]; got != 0.6 {
t.Errorf("art rock = %v, want 0.6 (down-weighted)", got)
}
if got := m["experimental"]; got != 0.3 { // 0.5 × 0.6
t.Errorf("experimental = %v, want 0.3", got)
}
}
func TestMusicBrainzFetch_ArtistOnlyWhenNoRecordingMBID(t *testing.T) {
srv := mbEntityServer(``, `{"tags":[{"count":1,"name":"folk"}]}`)
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
tags, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{ArtistName: "A", Title: "B", ArtistMBID: "art-2"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
if got := tagsByName(tags)["folk"]; got != 0.6 {
t.Errorf("folk = %v, want 0.6", got)
}
}
func TestMusicBrainzFetch_RecordingPreferredOverArtist(t *testing.T) {
// Recording has tags → they win at full weight; artist is never consulted.
srv := mbEntityServer(`{"tags":[{"count":3,"name":"shoegaze"}]}`,
`{"tags":[{"count":9,"name":"rock"}]}`)
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
tags, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{MBID: "rec-2", ArtistMBID: "art-3"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
m := tagsByName(tags)
if got := m["shoegaze"]; got != 1.0 {
t.Errorf("shoegaze = %v, want 1.0 (recording, full weight)", got)
}
if _, ok := m["rock"]; ok {
t.Error("artist tag 'rock' leaked in — recording tags should win outright")
}
}
func TestMusicBrainzFetch_ParsesTags(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"tags":[{"count":2,"name":"post-punk"},{"count":4,"name":"melancholic"}]}`))
}))
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
tags, err := newMBProvider(true).FetchTrackTags(context.Background(), TrackRef{MBID: "mbid-1"})
if err != nil {
t.Fatalf("fetch: %v", err)
}
m := tagsByName(tags)
if m["melancholic"] != 1.0 || m["post-punk"] != 0.5 {
t.Errorf("weights = %v, want melancholic=1.0 post-punk=0.5", m)
}
}
func TestMusicBrainzFetch_EmptyTags(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"tags":[]}`))
}))
defer srv.Close()
old := mbBaseURL
mbBaseURL = srv.URL
defer func() { mbBaseURL = old }()
if _, err := newMBProvider(true).FetchTrackTags(context.Background(),
TrackRef{MBID: "mbid-1"}); !errors.Is(err, ErrNotFound) {
t.Errorf("empty tags: err = %v, want ErrNotFound", err)
}
}
+85
View File
@@ -0,0 +1,85 @@
package tags
import "testing"
// fakeProvider is a minimal Provider for registry mechanics tests.
type fakeProvider struct {
id string
deflt bool
needKey bool
}
func (f *fakeProvider) ID() string { return f.id }
func (f *fakeProvider) DisplayName() string { return f.id }
func (f *fakeProvider) RequiresAPIKey() bool { return f.needKey }
func (f *fakeProvider) DefaultEnabled() bool { return f.deflt }
func (f *fakeProvider) Configure(ProviderSettings) error { return nil }
func TestRegisterAndLookup(t *testing.T) {
ResetRegistryForTests()
t.Cleanup(ResetRegistryForTests)
a := &fakeProvider{id: "alpha", deflt: true}
b := &fakeProvider{id: "beta"}
Register(a)
Register(b)
all := AllProviders()
if len(all) != 2 || all[0].ID() != "alpha" || all[1].ID() != "beta" {
t.Fatalf("AllProviders preserves registration order; got %v", all)
}
got, err := ProviderByID("beta")
if err != nil || got.ID() != "beta" {
t.Fatalf("ProviderByID(beta) = %v, %v", got, err)
}
if _, err := ProviderByID("missing"); err != ErrProviderNotFound {
t.Fatalf("ProviderByID(missing) err = %v, want ErrProviderNotFound", err)
}
}
func TestRegisterDuplicatePanics(t *testing.T) {
ResetRegistryForTests()
t.Cleanup(ResetRegistryForTests)
Register(&fakeProvider{id: "dup"})
defer func() {
if recover() == nil {
t.Error("expected panic on duplicate provider ID")
}
}()
Register(&fakeProvider{id: "dup"})
}
// TestBuiltinProvidersRegister confirms the two v1 sources register with
// the expected identities/defaults. Runs against a fresh registry so it is
// independent of test ordering: re-register constructed instances.
func TestBuiltinProvidersRegister(t *testing.T) {
ResetRegistryForTests()
t.Cleanup(ResetRegistryForTests)
Register(&musicbrainzProvider{})
Register(&lastfmProvider{})
mb, err := ProviderByID("musicbrainz")
if err != nil {
t.Fatalf("musicbrainz not registered: %v", err)
}
if mb.RequiresAPIKey() || !mb.DefaultEnabled() {
t.Errorf("musicbrainz should be keyless + default-on; got key=%v deflt=%v",
mb.RequiresAPIKey(), mb.DefaultEnabled())
}
lf, err := ProviderByID("lastfm")
if err != nil {
t.Fatalf("lastfm not registered: %v", err)
}
if !lf.RequiresAPIKey() || lf.DefaultEnabled() {
t.Errorf("lastfm should require a key + default-off; got key=%v deflt=%v",
lf.RequiresAPIKey(), lf.DefaultEnabled())
}
// Both implement the tag-fetch + test capabilities.
if _, ok := mb.(TrackTagProvider); !ok {
t.Error("musicbrainz must implement TrackTagProvider")
}
if _, ok := lf.(TestableProvider); !ok {
t.Error("lastfm must implement TestableProvider")
}
}
+305
View File
@@ -0,0 +1,305 @@
package tags
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"log/slog"
"sort"
"strings"
"sync"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// SettingsService reconciles the compiled-in provider registry with the
// tag_provider_settings table at boot and on every admin update, and owns
// the tag_sources_meta current_version stamp. Parallel to (and independent
// of) coverart's SettingsService — the two never share a row, so a new tag
// source is added without touching art settings.
type SettingsService struct {
pool *pgxpool.Pool
logger *slog.Logger
mu sync.RWMutex
enabledIDs map[string]bool
apiKeys map[string]string
currentVersion int32
}
// ProviderUpdatePatch is the admin-PATCH body. nil pointers mean "leave
// unchanged"; non-nil means "set" (empty APIKey clears it).
type ProviderUpdatePatch struct {
Enabled *bool
APIKey *string
}
// ProviderInfo is the settings view of a provider for the admin GET handler.
type ProviderInfo struct {
ID string
DisplayName string
RequiresAPIKey bool
Supports []string
Enabled bool
APIKeySet bool
DisplayOrder int32
Testable bool
}
// NewSettingsService boots the service: upserts default rows for any
// registered provider, Configures each with its row, and loads the version.
func NewSettingsService(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*SettingsService, error) {
s := &SettingsService{
pool: pool,
logger: logger,
enabledIDs: map[string]bool{},
apiKeys: map[string]string{},
}
if err := s.reconcile(ctx); err != nil {
return nil, fmt.Errorf("tag settings boot: %w", err)
}
return s, nil
}
// reconcile reads the DB, inserts defaults for missing providers, Configures
// each registered provider, and caches the current version.
func (s *SettingsService) reconcile(ctx context.Context) error {
q := dbq.New(s.pool)
registered := AllProviders()
for i, p := range registered {
if err := q.UpsertTagProviderSettings(ctx, dbq.UpsertTagProviderSettingsParams{
ProviderID: p.ID(),
Enabled: p.DefaultEnabled(),
DisplayOrder: int32(i),
}); err != nil {
return fmt.Errorf("upsert provider %q: %w", p.ID(), err)
}
}
rows, err := q.ListTagProviderSettings(ctx)
if err != nil {
return fmt.Errorf("list provider settings: %w", err)
}
settingsByID := map[string]dbq.TagProviderSetting{}
for _, r := range rows {
settingsByID[r.ProviderID] = r
}
s.mu.Lock()
defer s.mu.Unlock()
s.enabledIDs = map[string]bool{}
s.apiKeys = map[string]string{}
for _, p := range registered {
row, ok := settingsByID[p.ID()]
if !ok {
s.logger.Warn("tag settings: missing row after upsert", "provider", p.ID())
continue
}
key := ""
if row.ApiKey != nil {
key = *row.ApiKey
}
s.enabledIDs[p.ID()] = row.Enabled
s.apiKeys[p.ID()] = key
if err := p.Configure(ProviderSettings{Enabled: row.Enabled, APIKey: key}); err != nil {
s.logger.Warn("tag settings: provider Configure failed", "provider", p.ID(), "err", err)
}
}
v, err := q.GetCurrentTagSourcesVersion(ctx)
if err != nil {
return fmt.Errorf("get current tag sources version: %w", err)
}
s.currentVersion = v
return nil
}
// EnabledTrackTagProviders returns the enabled providers implementing
// TrackTagProvider, in registration order. Snapshot — do not mutate.
func (s *SettingsService) EnabledTrackTagProviders() []TrackTagProvider {
s.mu.RLock()
defer s.mu.RUnlock()
var out []TrackTagProvider
for _, p := range AllProviders() {
if !s.enabledIDs[p.ID()] {
continue
}
if tp, ok := p.(TrackTagProvider); ok {
out = append(out, tp)
}
}
return out
}
// CurrentVersion returns the version the enricher stamps onto rows.
func (s *SettingsService) CurrentVersion() int32 {
s.mu.RLock()
defer s.mu.RUnlock()
return s.currentVersion
}
// UpdateProvider applies an admin patch. Returns versionBumped=true if the
// enabled set changed (not on key-only changes). Re-Configures the provider.
func (s *SettingsService) UpdateProvider(ctx context.Context, providerID string, patch ProviderUpdatePatch) (bool, error) {
p, err := ProviderByID(providerID)
if err != nil {
return false, err
}
q := dbq.New(s.pool)
s.mu.RLock()
beforeSig := enabledSetSignature(s.enabledIDs)
currentEnabled := s.enabledIDs[providerID]
s.mu.RUnlock()
enabledVal := currentEnabled
if patch.Enabled != nil {
enabledVal = *patch.Enabled
}
if err := q.UpdateTagProviderSettings(ctx, dbq.UpdateTagProviderSettingsParams{
ProviderID: providerID,
Enabled: enabledVal,
Column3: patch.APIKey != nil,
ApiKey: patch.APIKey,
}); err != nil {
return false, fmt.Errorf("update provider settings: %w", err)
}
rows, err := q.ListTagProviderSettings(ctx)
if err != nil {
return false, fmt.Errorf("re-list provider settings: %w", err)
}
s.mu.Lock()
defer s.mu.Unlock()
for _, r := range rows {
key := ""
if r.ApiKey != nil {
key = *r.ApiKey
}
s.enabledIDs[r.ProviderID] = r.Enabled
s.apiKeys[r.ProviderID] = key
if r.ProviderID == providerID {
if err := p.Configure(ProviderSettings{Enabled: r.Enabled, APIKey: key}); err != nil {
s.logger.Warn("tag settings: Configure after update failed", "provider", providerID, "err", err)
}
}
}
if enabledSetSignature(s.enabledIDs) == beforeSig {
return false, nil
}
newVersion, err := q.BumpTagSourcesVersion(ctx)
if err != nil {
return false, fmt.Errorf("bump tag sources version: %w", err)
}
s.currentVersion = newVersion
return true, nil
}
// enabledSetSignature is a deterministic string of the enabled provider IDs.
func enabledSetSignature(enabledIDs map[string]bool) string {
ids := make([]string, 0, len(enabledIDs))
for id, on := range enabledIDs {
if on {
ids = append(ids, id)
}
}
sort.Strings(ids)
return strings.Join(ids, ",")
}
// BumpVersionIfProvidersChanged bumps the version + stores the new provider
// hash when the compiled-in provider set changed since the last boot, so
// 'none' rows become eligible for a retry through the widened chain. This
// is what makes "add a source" a zero-touch change — a new registered
// provider trips this on the next start.
func (s *SettingsService) BumpVersionIfProvidersChanged(ctx context.Context) (version int32, bumped bool, err error) {
hash := registeredProvidersHash()
q := dbq.New(s.pool)
stored, qerr := q.GetTagProvidersHash(ctx)
if qerr != nil {
return 0, false, fmt.Errorf("get providers hash: %w", qerr)
}
if stored == hash {
return s.CurrentVersion(), false, nil
}
newVer, berr := q.BumpTagVersionAndSetProvidersHash(ctx, hash)
if berr != nil {
return 0, false, fmt.Errorf("bump version: %w", berr)
}
s.mu.Lock()
s.currentVersion = newVer
s.mu.Unlock()
return newVer, true, nil
}
// registeredProvidersHash is the SHA-256 hex of the sorted, colon-joined
// registered provider IDs.
func registeredProvidersHash() string {
provs := AllProviders()
ids := make([]string, 0, len(provs))
for _, p := range provs {
ids = append(ids, p.ID())
}
sort.Strings(ids)
sum := sha256.Sum256([]byte(strings.Join(ids, ":")))
return hex.EncodeToString(sum[:])
}
// BumpVersion unconditionally increments the version. Used by the admin
// "re-search missing tags" action to re-attempt every 'none' row next pass.
func (s *SettingsService) BumpVersion(ctx context.Context) (int32, error) {
q := dbq.New(s.pool)
newVer, err := q.BumpTagVersionAndSetProvidersHash(ctx, registeredProvidersHash())
if err != nil {
return 0, fmt.Errorf("bump version: %w", err)
}
s.mu.Lock()
s.currentVersion = newVer
s.mu.Unlock()
return newVer, nil
}
// TestProvider invokes TestConnection if the provider supports it.
func (s *SettingsService) TestProvider(ctx context.Context, providerID string) error {
p, err := ProviderByID(providerID)
if err != nil {
return err
}
tp, ok := p.(TestableProvider)
if !ok {
return ErrNotTestable
}
return tp.TestConnection(ctx)
}
// ListProviderInfo builds the admin-GET view in registration order.
func (s *SettingsService) ListProviderInfo() []ProviderInfo {
s.mu.RLock()
defer s.mu.RUnlock()
var out []ProviderInfo
for i, p := range AllProviders() {
info := ProviderInfo{
ID: p.ID(),
DisplayName: p.DisplayName(),
RequiresAPIKey: p.RequiresAPIKey(),
Enabled: s.enabledIDs[p.ID()],
APIKeySet: s.apiKeys[p.ID()] != "",
DisplayOrder: int32(i),
}
if _, ok := p.(TrackTagProvider); ok {
info.Supports = append(info.Supports, "track_tags")
}
if _, ok := p.(TestableProvider); ok {
info.Testable = true
}
out = append(out, info)
}
return out
}
+59
View File
@@ -0,0 +1,59 @@
package tags
import (
"context"
"log/slog"
"time"
)
// Worker periodically drains tracks needing tag enrichment. Unlike cover
// art (threaded through the file-scan chain because it needs track file
// paths), tag enrichment only needs data already in the DB — recording
// MBID / artist / title — so it runs as a standalone background worker,
// mirroring the ListenBrainz similarity worker. Rate limiting lives in the
// providers' httpClients, so a tick just drains a bounded batch and the
// external APIs pace themselves.
type Worker struct {
enricher *Enricher
logger *slog.Logger
tick time.Duration
batch int
}
// NewWorker constructs a worker with production defaults: an initial drain
// shortly after boot, then every 30 minutes, up to 200 tracks per tick.
// MusicBrainz's 1 req/s ceiling is the real throttle, so the batch size
// mainly bounds how long one tick runs, not the request rate.
func NewWorker(enricher *Enricher, logger *slog.Logger) *Worker {
return &Worker{
enricher: enricher,
logger: logger,
tick: 30 * time.Minute,
batch: 200,
}
}
// Run blocks until ctx is cancelled: an initial drain, then every w.tick.
func (w *Worker) Run(ctx context.Context) {
w.tickOnce(ctx)
t := time.NewTicker(w.tick)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.tickOnce(ctx)
}
}
}
// tickOnce drains one bounded batch. EnrichTrackBatch already logs a
// category breakdown, so this only surfaces a fatal batch error.
func (w *Worker) tickOnce(ctx context.Context) {
if _, _, _, err := w.enricher.EnrichTrackBatch(ctx, w.batch, nil); err != nil {
if ctx.Err() == nil {
w.logger.Error("tags: enrichment tick failed", "err", err)
}
}
}
+183 -28
View File
@@ -16,6 +16,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
)
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
@@ -36,6 +37,31 @@ type Config struct {
TrackLikeBonus float64
TagLikeBonus float64
// EnrichedTagScale weights folksonomy tags (from the track_tags cache,
// #1490) relative to a track's raw ID3 genre when both fold into the tag
// facet. Each enriched tag contributes base × tag.weight × this scale,
// where base is the play's decayed engagement (or the tag-like bonus for
// a liked track) and tag.weight is the normalized folksonomy strength in
// [0,1]. Below 1 so the richer-but-noisier enriched vocabulary augments
// the ID3 genre signal without swamping it. 0 disables the enriched
// contribution entirely (falls back to genre-only).
EnrichedTagScale float64
// EraScale weights the decade/era facet (#1530): each play adds
// base × this scale to its album's decade bucket ("1990s"), where base
// is the decayed engagement (or the tag-like bonus for a liked track).
// Era is a coarse signal, so it's kept ≤ 1 to imprint at most as strongly
// as the raw engagement; 0 disables the era facet entirely. Tunable in the
// lab (mirrors EnrichedTagScale, #1520).
EraScale float64
// MoodScale weights the mood facet (#1534): each play adds base × this
// scale to the canonical mood buckets (melancholic / chill / …) derived
// from its enriched folksonomy tags via internal/mood. Coverage is partial
// (depends on tag enrichment), so it's a supplement kept ≤ 1; 0 disables
// the mood facet entirely. Tunable in the lab (mirrors EraScale).
MoodScale float64
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
// go. Aggregation already protects an artist the user likes (one skip
// nets out against many good plays); the floor additionally bounds the
@@ -48,29 +74,42 @@ type Config struct {
WeightEpsilon float64
// Size caps (guard rails for pathological libraries). Persist the top
// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags.
// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags
// and eras. Real libraries span ~a dozen decades, so the era caps rarely
// bind — they exist for symmetry with the other facets.
MaxArtists int
MaxNegArtists int
MaxTags int
MaxNegTags int
MaxEras int
MaxNegEras int
MaxMoods int
MaxNegMoods int
}
// DefaultConfig returns the tuned starting configuration.
func DefaultConfig() Config {
return Config{
Engagement: DefaultEngagementParams(),
HalfLifeDays: 75,
WindowDays: 270,
ArtistLikeBonus: 3.0,
TrackLikeBonus: 1.0,
TagLikeBonus: 0.5,
ArtistFloor: -3.0,
TagFloor: -3.0,
WeightEpsilon: 0.05,
MaxArtists: 500,
MaxNegArtists: 150,
MaxTags: 200,
MaxNegTags: 60,
Engagement: DefaultEngagementParams(),
HalfLifeDays: 75,
WindowDays: 270,
ArtistLikeBonus: 3.0,
TrackLikeBonus: 1.0,
TagLikeBonus: 0.5,
EnrichedTagScale: 0.5,
EraScale: 0.5,
MoodScale: 0.5,
ArtistFloor: -3.0,
TagFloor: -3.0,
WeightEpsilon: 0.05,
MaxArtists: 500,
MaxNegArtists: 150,
MaxTags: 200,
MaxNegTags: 60,
MaxEras: 30,
MaxNegEras: 15,
MaxMoods: 20,
MaxNegMoods: 10,
}
}
@@ -90,72 +129,168 @@ func BuildTasteProfile(
) error {
q := dbq.New(pool)
artistW, tagW, err := accumulate(ctx, q, userID, cfg)
artistW, tagW, eraW, moodW, err := accumulate(ctx, q, userID, cfg)
if err != nil {
return err
}
applyFloor(artistW, cfg.ArtistFloor)
applyFloor(tagW, cfg.TagFloor)
// Era + mood share the tag floor (all text-keyed facets; a decade or mood
// that's been heavily skipped shouldn't dominate as an extreme negative).
applyFloor(eraW, cfg.TagFloor)
applyFloor(moodW, cfg.TagFloor)
strLess := func(a, b string) bool { return a < b }
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags,
func(a, b string) bool { return a < b })
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
eras := rankWeighted(eraW, cfg.WeightEpsilon, cfg.MaxEras, cfg.MaxNegEras, strLess)
moods := rankWeighted(moodW, cfg.WeightEpsilon, cfg.MaxMoods, cfg.MaxNegMoods, strLess)
if err := persist(ctx, pool, userID, artists, tags); err != nil {
if err := persist(ctx, pool, userID, artists, tags, eras, moods); err != nil {
return err
}
logger.Debug("taste: profile rebuilt",
"user_id", uuidString(userID), "artists", len(artists), "tags", len(tags))
logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID),
"artists", len(artists), "tags", len(tags), "eras", len(eras), "moods", len(moods))
return nil
}
// accumulate sums decayed play engagement and like bonuses into artist/tag
// weight maps.
// accumulate sums decayed play engagement and like bonuses into
// artist/tag/era/mood weight maps.
func accumulate(
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
) (map[pgtype.UUID]float64, map[string]float64, error) {
) (map[pgtype.UUID]float64, map[string]float64, map[string]float64, map[string]float64, error) {
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
UserID: userID,
Column2: cfg.WindowDays,
})
if err != nil {
return nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
return nil, nil, nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
}
// Enriched folksonomy tags keyed by track (#1490) — folded into the tag
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
// "post-punk / shoegaze / melancholic" vocabulary. Window matches the
// play window; a track with no cached tags simply contributes genre only.
playedTagRows, err := q.ListPlayedTrackTagsForUser(ctx, dbq.ListPlayedTrackTagsForUserParams{
UserID: userID,
Column2: int32(cfg.WindowDays),
})
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
}
playedTags := groupTagsByTrack(playedTagRows)
artistW := make(map[pgtype.UUID]float64)
tagW := make(map[string]float64)
eraW := make(map[string]float64)
moodW := make(map[string]float64)
for _, p := range plays {
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
artistW[p.ArtistID] += e
for _, tag := range splitGenres(p.Genre) {
tagW[tag] += e
}
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
foldEra(eraW, p.ReleaseDate, e, cfg.EraScale)
foldMoods(moodW, playedTags[p.TrackID], e, cfg.MoodScale)
}
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
if err != nil {
return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
return nil, nil, nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
}
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
}
likedTags := groupTagsByTrack(likedTagRows)
for _, lt := range likedTracks {
artistW[lt.ArtistID] += cfg.TrackLikeBonus
for _, tag := range splitGenres(lt.Genre) {
tagW[tag] += cfg.TagLikeBonus
}
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
foldEra(eraW, lt.ReleaseDate, cfg.TagLikeBonus, cfg.EraScale)
foldMoods(moodW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.MoodScale)
}
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
if err != nil {
return nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
return nil, nil, nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
}
for _, aid := range likedArtists {
artistW[aid] += cfg.ArtistLikeBonus
}
return artistW, tagW, nil
return artistW, tagW, eraW, moodW, nil
}
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
// each play/like can fold in its track's enriched tags in one lookup.
func groupTagsByTrack(rows []dbq.TrackTag) map[pgtype.UUID][]dbq.TrackTag {
m := make(map[pgtype.UUID][]dbq.TrackTag)
for _, r := range rows {
m[r.TrackID] = append(m[r.TrackID], r)
}
return m
}
// foldEnrichedTags adds each enriched tag to tagW weighted by
// base × tag.weight × scale — base is the play's decayed engagement or the
// tag-like bonus, tag.weight is the folksonomy strength in [0,1]. scale=0
// disables the enriched contribution (genre-only fallback).
func foldEnrichedTags(tagW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
if scale == 0 {
return
}
for _, t := range tags {
tagW[t.Tag] += base * t.Weight * scale
}
}
// foldEra adds a track's decade bucket to eraW weighted by base × scale — base
// is the play's decayed engagement or the tag-like bonus, scale (EraScale)
// dials the facet's strength. scale=0 disables the era facet; undated tracks
// (decade "") contribute nothing so they don't pollute the profile.
func foldEra(eraW map[string]float64, d pgtype.Date, base, scale float64) {
if scale == 0 {
return
}
if decade := decadeOf(d); decade != "" {
eraW[decade] += base * scale
}
}
// foldMoods adds a track's canonical mood buckets (derived from its enriched
// folksonomy tags via internal/mood) to moodW weighted by base × scale. base is
// the play's decayed engagement or the tag-like bonus; scale (MoodScale) dials
// the facet's strength. scale=0 disables the mood facet; tracks with no
// mood-word tags contribute nothing.
func foldMoods(moodW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
if scale == 0 {
return
}
names := make([]string, len(tags))
for i, t := range tags {
names[i] = t.Tag
}
for _, m := range mood.Of(names) {
moodW[m] += base * scale
}
}
// decadeOf maps an album release date to a decade bucket like "1990s", or ""
// for an absent/invalid date. Mirrored by the recommendation scorer so a
// candidate's era is derived the same way it was learned.
func decadeOf(d pgtype.Date) string {
if !d.Valid {
return ""
}
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
}
// persist atomic-replaces the user's profile rows inside one transaction.
func persist(
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
artists []weighted[pgtype.UUID], tags []weighted[string],
artists []weighted[pgtype.UUID], tags, eras, moods []weighted[string],
) error {
tx, err := pool.Begin(ctx)
if err != nil {
@@ -184,6 +319,26 @@ func persist(
return fmt.Errorf("taste: insert tag: %w", err)
}
}
if err := qtx.DeleteTasteProfileErasForUser(ctx, userID); err != nil {
return fmt.Errorf("taste: delete eras: %w", err)
}
for _, e := range eras {
if err := qtx.InsertTasteProfileEra(ctx, dbq.InsertTasteProfileEraParams{
UserID: userID, Era: e.Key, Weight: e.Weight,
}); err != nil {
return fmt.Errorf("taste: insert era: %w", err)
}
}
if err := qtx.DeleteTasteProfileMoodsForUser(ctx, userID); err != nil {
return fmt.Errorf("taste: delete moods: %w", err)
}
for _, m := range moods {
if err := qtx.InsertTasteProfileMood(ctx, dbq.InsertTasteProfileMoodParams{
UserID: userID, Mood: m.Key, Weight: m.Weight,
}); err != nil {
return fmt.Errorf("taste: insert mood: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("taste: commit: %w", err)
}
+29
View File
@@ -4,10 +4,39 @@ import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
func strLess(a, b string) bool { return a < b }
func TestFoldEnrichedTags(t *testing.T) {
tagW := map[string]float64{"rock": 1.0} // pre-existing ID3 genre weight
tags := []dbq.TrackTag{
{Tag: "shoegaze", Weight: 1.0},
{Tag: "melancholic", Weight: 0.5},
{Tag: "rock", Weight: 0.4}, // overlaps genre → accumulates
}
// base=2 (engagement), scale=0.5 → contribution = 2 * weight * 0.5 = weight.
foldEnrichedTags(tagW, tags, 2.0, 0.5)
if got := tagW["shoegaze"]; got != 1.0 {
t.Errorf("shoegaze = %v, want 1.0", got)
}
if got := tagW["melancholic"]; got != 0.5 {
t.Errorf("melancholic = %v, want 0.5", got)
}
if got := tagW["rock"]; got != 1.4 { // 1.0 genre + 2*0.4*0.5
t.Errorf("rock = %v, want 1.4", got)
}
// scale=0 disables the enriched contribution entirely.
base := map[string]float64{"rock": 1.0}
foldEnrichedTags(base, tags, 2.0, 0)
if len(base) != 1 || base["rock"] != 1.0 {
t.Errorf("scale=0 should leave tagW untouched; got %v", base)
}
}
func TestRankWeighted_DropsEpsilonAndSortsDesc(t *testing.T) {
m := map[string]float64{"a": 3.0, "b": 1.0, "tiny": 0.01, "d": -2.0}
got := rankWeighted(m, 0.05, 10, 10, strLess)
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getTagProviders,
updateTagProvider,
testTagProvider,
type TagProvider,
type TagProvidersResponse
} from './admin';
vi.mock('./client', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
}));
import { api } from './client';
describe('admin tag-sources API', () => {
beforeEach(() => vi.clearAllMocks());
it('getTagProviders GETs the correct path', async () => {
const sample: TagProvidersResponse = {
providers: [
{
id: 'musicbrainz',
display_name: 'MusicBrainz',
requires_api_key: false,
supports: ['track_tags'],
enabled: true,
api_key_set: false,
display_order: 0,
testable: true
}
],
sources_version: 1
};
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await getTagProviders();
expect(api.get).toHaveBeenCalledWith('/api/admin/tag-sources');
expect(got.providers[0].id).toBe('musicbrainz');
expect(got.sources_version).toBe(1);
});
it('updateTagProvider PATCHes the right path with patch body', async () => {
const patched: TagProvider & { version_bumped: boolean } = {
id: 'lastfm',
display_name: 'Last.fm',
requires_api_key: true,
supports: ['track_tags'],
enabled: true,
api_key_set: true,
display_order: 1,
testable: true,
version_bumped: true
};
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(patched);
const got = await updateTagProvider('lastfm', { enabled: true, api_key: 'mykey' });
expect(api.patch).toHaveBeenCalledWith(
'/api/admin/tag-sources/lastfm',
{ enabled: true, api_key: 'mykey' }
);
expect(got.api_key_set).toBe(true);
expect(got.version_bumped).toBe(true);
});
it('testTagProvider POSTs to /test endpoint with empty body', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
duration_ms: 55
});
const got = await testTagProvider('musicbrainz');
expect(api.post).toHaveBeenCalledWith('/api/admin/tag-sources/musicbrainz/test', {});
expect(got.ok).toBe(true);
expect(got.duration_ms).toBe(55);
});
it('testTagProvider returns ok=false with error string on failure', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
error: 'lastfm api key not set'
});
const got = await testTagProvider('lastfm');
expect(got.ok).toBe(false);
expect(got.error).toBe('lastfm api key not set');
});
});

Some files were not shown because too many files have changed in this diff Show More