diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminTagSourcesRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminTagSourcesRepository.kt new file mode 100644 index 00000000..9a6e9416 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/data/AdminTagSourcesRepository.kt @@ -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 = 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) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt index 6f0b0a27..0a60d78b 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminLandingScreen.kt @@ -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.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) }, + ) + } } } diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesScreen.kt new file mode 100644 index 00000000..cc6f0cf8 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesScreen.kt @@ -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, + testResults: Map, + 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, + ) + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesViewModel.kt b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesViewModel.kt new file mode 100644 index 00000000..53cbdfaa --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/admin/ui/AdminTagSourcesViewModel.kt @@ -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, + val testResults: Map, + ) : 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.Loading) + val uiState: StateFlow = 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 }, + ) + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminTagSourcesApi.kt b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminTagSourcesApi.kt new file mode 100644 index 00000000..e02ebeec --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/api/endpoints/AdminTagSourcesApi.kt @@ -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, +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt index 607f2839..79dda6ce 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationQueue.kt @@ -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, ) /** diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt index a09c1301..c3ddfb2d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/mutations/MutationReplayer.kt @@ -254,6 +254,7 @@ class MutationReplayer @Inject constructor( at = decoded.atIso, durationPlayedMs = decoded.durationPlayedMs, source = decoded.source, + deviceClass = decoded.deviceClass, ), ) return Outcome.SENT diff --git a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt index c7d7e6da..defdb766 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/home/ui/HomeScreen.kt @@ -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(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 = 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> = 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, + 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, + 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>, onAlbumClick: (String) -> Unit, @@ -811,13 +969,14 @@ private fun AlbumsRow( @Composable private fun PlaylistsRow( + title: String, rowItems: List, 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, 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, + status: SystemPlaylistsStatus, + offline: Boolean, +): List { + 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): List { val out = mutableListOf() 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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/AdminTagSourceRef.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminTagSourceRef.kt new file mode 100644 index 00000000..f892c7aa --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/AdminTagSourceRef.kt @@ -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, + 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 = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminTagSourceWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminTagSourceWire.kt new file mode 100644 index 00000000..375f3a3a --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/AdminTagSourceWire.kt @@ -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 = 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 = 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 = "", +) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt index 74d34d96..47072c12 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/models/wire/EventsWire.kt @@ -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, ) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt index 987844b6..6f03705c 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/MinstrelNavGraph.kt @@ -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 { + WithAnimatedScope { + ShellScaffold(onExpandPlayer = expandPlayer) { + AdminTagSourcesScreen(navController = navController) + } + } + } } private fun NavGraphBuilder.outsideShell(navController: NavHostController) { diff --git a/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt index 24509fe6..6e470f85 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/nav/Routes.kt @@ -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 ────────────────────────────────── diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayEventsReporter.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayEventsReporter.kt index 0325fba9..c62cdb32 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayEventsReporter.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayEventsReporter.kt @@ -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 diff --git a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CoverTile.kt b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CoverTile.kt index b51ef75f..1e16bdf4 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CoverTile.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/shared/widgets/CoverTile.kt @@ -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() } diff --git a/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt index ed09e992..2315074a 100644 --- a/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt +++ b/android/app/src/test/java/com/fabledsword/minstrel/home/ui/BuildPlaylistsRowTest.kt @@ -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().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().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().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()) + } } diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index b9e9d00c..b7f1248e 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -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 artist–artist + // co-occurrence edges (source='user_cooccurrence') from play_events every + // 6h — a collaborative candidate arm for the radio/mix pools. Pure local + // SQL, no external calls; empty on single-user servers. + go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx) + + // Start the tag-enrichment worker (#1490). Reconciles the compiled-in + // tag providers with tag_provider_settings, bumps the sources version if + // the provider set changed (re-opening settled rows), then drains tracks + // needing folksonomy tags on a periodic tick. Standalone (not in the file + // scan chain) because tag lookups need only DB fields — MBID / artist / + // title — that a scan has already imported. + tagSettings, err := tags.NewSettingsService(ctx, pool, logger.With("component", "tags")) + if err != nil { + logger.Error("tag settings service init failed", "err", err) + os.Exit(1) + } + if newVer, bumped, berr := tagSettings.BumpVersionIfProvidersChanged(ctx); berr != nil { + logger.Warn("tags: provider-hash boot check failed", "err", berr) + } else if bumped { + logger.Info("tags: registered provider set changed; version bumped", "new_version", newVer) + } + tagEnricher := tags.NewEnricher(pool, logger.With("component", "tags"), tagSettings) + go tags.NewWorker(tagEnricher, logger.With("component", "tags")).Run(ctx) + // Start the GC worker. Runs every 1h and sweeps lifecycle tables // 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, diff --git a/internal/api/admin_recommendation_tuning.go b/internal/api/admin_recommendation_tuning.go index 1a556d9d..a97276c5 100644 --- a/internal/api/admin_recommendation_tuning.go +++ b/internal/api/admin_recommendation_tuning.go @@ -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, } } diff --git a/internal/api/admin_tag_sources.go b/internal/api/admin_tag_sources.go new file mode 100644 index 00000000..cc9b534b --- /dev/null +++ b/internal/api/admin_tag_sources.go @@ -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}) +} diff --git a/internal/api/admin_tag_sources_test.go b/internal/api/admin_tag_sources_test.go new file mode 100644 index 00000000..cb00a0a8 --- /dev/null +++ b/internal/api/admin_tag_sources_test.go @@ -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") + } +} diff --git a/internal/api/api.go b/internal/api/api.go index a8649fb7..a16f8f23 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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 diff --git a/internal/api/events.go b/internal/api/events.go index 150f534f..191d1688 100644 --- a/internal/api/events.go +++ b/internal/api/events.go @@ -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)) diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 370da98e..97544d40 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -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", diff --git a/internal/api/me_recommendation_metrics.go b/internal/api/me_recommendation_metrics.go index 75761b05..7aa02bb1 100644 --- a/internal/api/me_recommendation_metrics.go +++ b/internal/api/me_recommendation_metrics.go @@ -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", "", } diff --git a/internal/api/me_recommendation_metrics_test.go b/internal/api/me_recommendation_metrics_test.go index de717b79..a5a6946e 100644 --- a/internal/api/me_recommendation_metrics_test.go +++ b/internal/api/me_recommendation_metrics_test.go @@ -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{ diff --git a/internal/api/radio.go b/internal/api/radio.go index 5aa68f6c..b39ae73f 100644 --- a/internal/api/radio.go +++ b/internal/api/radio.go @@ -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. diff --git a/internal/coplay/worker.go b/internal/coplay/worker.go new file mode 100644 index 00000000..ed2c0435 --- /dev/null +++ b/internal/coplay/worker.go @@ -0,0 +1,86 @@ +// Package coplay builds household co-play similarity edges (#1533, milestone +// #160 Opt 5). Minstrel is a single shared-library, multi-user server (no +// per-user library ACL), so the "household" is the whole instance's user set. +// A periodic worker recomputes artist–artist co-occurrence from play_events +// entirely in SQL (no external API) and stores the edges in artist_similarity +// under source='user_cooccurrence' — the pre-provisioned co-occurrence source +// from the 0009 schema — which the radio/mix candidate pool consumes as a +// collaborative arm. Single-user servers produce no edges (the >= 2 co-player +// gate), so the feature is a graceful no-op there. +package coplay + +import ( + "context" + "log/slog" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// minJaccard is the co-play edge floor: prune weak pairs and self-limit hub +// artists, whose large combined-audience denominators pull their Jaccard down. +// 0.1 keeps only pairs whose co-player overlap is a real fraction of their +// combined audience. +const minJaccard = 0.1 + +// Worker periodically rebuilds the household co-play edge set. Pure local SQL, +// so there are no rate limits — but the pair self-join is roughly +// O(users × artists_per_user²), so it ticks slowly: co-listening shifts over +// days, not minutes, and a frequent rebuild would burn CPU for no fresher +// signal. +type Worker struct { + pool *pgxpool.Pool + logger *slog.Logger + tick time.Duration +} + +// NewWorker constructs a worker with the production default 6h tick. +func NewWorker(pool *pgxpool.Pool, logger *slog.Logger) *Worker { + return &Worker{pool: pool, logger: logger, tick: 6 * time.Hour} +} + +// Run rebuilds once at startup (so edges exist without waiting a full tick), +// then every w.tick until ctx is cancelled. Runs in its own goroutine, so a +// slow initial rebuild never blocks boot. +func (w *Worker) Run(ctx context.Context) { + if err := w.rebuild(ctx); err != nil { + w.logger.Error("coplay: initial rebuild failed", "err", err) + } + t := time.NewTicker(w.tick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := w.rebuild(ctx); err != nil { + w.logger.Error("coplay: rebuild failed", "err", err) + } + } + } +} + +// rebuild atomic-replaces the user_cooccurrence edge set inside one +// transaction — delete the old edges, recompute from current play history — so +// the candidate pool never observes a half-built set. +func (w *Worker) rebuild(ctx context.Context) error { + tx, err := w.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(ctx) }() + q := dbq.New(tx) + if err := q.DeleteArtistCoplayEdges(ctx); err != nil { + return err + } + if err := q.InsertArtistCoplayEdges(ctx, minJaccard); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return err + } + w.logger.Debug("coplay: edges rebuilt") + return nil +} diff --git a/internal/coplay/worker_integration_test.go b/internal/coplay/worker_integration_test.go new file mode 100644 index 00000000..72737eaf --- /dev/null +++ b/internal/coplay/worker_integration_test.go @@ -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) + } +} diff --git a/internal/db/dbq/coplay.sql.go b/internal/db/dbq/coplay.sql.go new file mode 100644 index 00000000..f19fef6a --- /dev/null +++ b/internal/db/dbq/coplay.sql.go @@ -0,0 +1,78 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: coplay.sql + +package dbq + +import ( + "context" +) + +const deleteArtistCoplayEdges = `-- name: DeleteArtistCoplayEdges :exec + +DELETE FROM artist_similarity WHERE source = 'user_cooccurrence' +` + +// Household co-play similarity (#1533, milestone #160 Opt 5). Minstrel is a +// single shared-library, multi-user server (no per-user library ACL), so the +// "household" is the whole instance's user set. These queries recompute +// artist–artist co-occurrence edges from play_events and store them in +// artist_similarity under the pre-provisioned source = 'user_cooccurrence' +// (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then +// re-INSERTs so edges that fall below threshold disappear. Single-user servers +// produce no edges (the >= 2 co-player gate), so the arm is empty there. +func (q *Queries) DeleteArtistCoplayEdges(ctx context.Context) error { + _, err := q.db.Exec(ctx, deleteArtistCoplayEdges) + return err +} + +const insertArtistCoplayEdges = `-- name: InsertArtistCoplayEdges :exec +WITH user_artists AS ( + SELECT DISTINCT pe.user_id, t.artist_id + FROM play_events pe + JOIN tracks t ON t.id = pe.track_id + WHERE pe.was_skipped = false + AND pe.started_at > now() - interval '365 days' +), +artist_players AS ( + SELECT artist_id, count(*)::float8 AS players + FROM user_artists + GROUP BY artist_id +), +pairs AS ( + SELECT ua.artist_id AS a_id, + ub.artist_id AS b_id, + count(*)::float8 AS coplayers + FROM user_artists ua + JOIN user_artists ub + ON ua.user_id = ub.user_id AND ua.artist_id <> ub.artist_id + GROUP BY ua.artist_id, ub.artist_id + HAVING count(*) >= 2 +), +scored AS ( + SELECT p.a_id, p.b_id, + p.coplayers / (pa.players + pb.players - p.coplayers) AS score + FROM pairs p + JOIN artist_players pa ON pa.artist_id = p.a_id + JOIN artist_players pb ON pb.artist_id = p.b_id +) +INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source, fetched_at) +SELECT a_id, b_id, score, 'user_cooccurrence', now() +FROM scored +WHERE score >= $1 +ON CONFLICT (artist_a_id, artist_b_id, source) +DO UPDATE SET score = EXCLUDED.score, fetched_at = EXCLUDED.fetched_at +` + +// Two artists are "co-played" when the same users play both. score is the +// Jaccard of their distinct-player sets — coplayers / (playersA + playersB − +// coplayers) in (0,1] — which controls for globally-popular artists (an artist +// everyone plays would otherwise co-occur with everything). Only pairs with +// >= 2 distinct co-players AND Jaccard >= $1 (a floor that both prunes weak +// edges and self-limits hub artists, whose large denominators drag their +// Jaccard down) are kept. Completed plays only, 365-day window. +func (q *Queries) InsertArtistCoplayEdges(ctx context.Context, score float64) error { + _, err := q.db.Exec(ctx, insertArtistCoplayEdges, score) + return err +} diff --git a/internal/db/dbq/discover.sql.go b/internal/db/dbq/discover.sql.go index f4f8266a..69161ec1 100644 --- a/internal/db/dbq/discover.sql.go +++ b/internal/db/dbq/discover.sql.go @@ -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 +} diff --git a/internal/db/dbq/events.sql.go b/internal/db/dbq/events.sql.go index 6db1800c..0fa111d6 100644 --- a/internal/db/dbq/events.sql.go +++ b/internal/db/dbq/events.sql.go @@ -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 } diff --git a/internal/db/dbq/history.sql.go b/internal/db/dbq/history.sql.go index 9ad6ae76..e2007993 100644 --- a/internal/db/dbq/history.sql.go +++ b/internal/db/dbq/history.sql.go @@ -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 { diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go index 3475c710..327d1b2d 100644 --- a/internal/db/dbq/likes.sql.go +++ b/internal/db/dbq/likes.sql.go @@ -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 } diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 29528972..10e990ef 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -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 diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index add1e34e..13167583 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -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 6–7 (Sat/Sun). +func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, arg ListArtistContextPlayCountsForUserParams) ([]ListArtistContextPlayCountsForUserRow, error) { + rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, arg.ID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListArtistContextPlayCountsForUserRow + for rows.Next() { + var i ListArtistContextPlayCountsForUserRow + if err := rows.Scan(&i.ArtistID, &i.TotalPlays, &i.CellPlays); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many 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 diff --git a/internal/db/dbq/recommendation_tuning.sql.go b/internal/db/dbq/recommendation_tuning.sql.go index d41c0f19..f90aaa97 100644 --- a/internal/db/dbq/recommendation_tuning.sql.go +++ b/internal/db/dbq/recommendation_tuning.sql.go @@ -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 } diff --git a/internal/db/dbq/system_playlists.sql.go b/internal/db/dbq/system_playlists.sql.go index 4f8d1a86..a86564a9 100644 --- a/internal/db/dbq/system_playlists.sql.go +++ b/internal/db/dbq/system_playlists.sql.go @@ -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 { diff --git a/internal/db/dbq/tag_settings.sql.go b/internal/db/dbq/tag_settings.sql.go new file mode 100644 index 00000000..61ee4524 --- /dev/null +++ b/internal/db/dbq/tag_settings.sql.go @@ -0,0 +1,157 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: tag_settings.sql + +package dbq + +import ( + "context" +) + +const bumpTagSourcesVersion = `-- name: BumpTagSourcesVersion :one +UPDATE tag_sources_meta + SET current_version = current_version + 1 + WHERE id = true +RETURNING current_version +` + +// Increment + return the new version. Called only when the enabled set +// changes (not on key edits). +func (q *Queries) BumpTagSourcesVersion(ctx context.Context) (int32, error) { + row := q.db.QueryRow(ctx, bumpTagSourcesVersion) + var current_version int32 + err := row.Scan(¤t_version) + return current_version, err +} + +const bumpTagVersionAndSetProvidersHash = `-- name: BumpTagVersionAndSetProvidersHash :one +UPDATE tag_sources_meta + SET current_version = current_version + 1, + last_registered_providers_hash = $1 + WHERE id = true +RETURNING current_version +` + +// Atomically increment the version and store the new registered-provider +// hash. Called at boot when the compiled-in provider set changed, so +// 'none' rows become eligible for a retry through the new chain. +func (q *Queries) BumpTagVersionAndSetProvidersHash(ctx context.Context, lastRegisteredProvidersHash string) (int32, error) { + row := q.db.QueryRow(ctx, bumpTagVersionAndSetProvidersHash, lastRegisteredProvidersHash) + var current_version int32 + err := row.Scan(¤t_version) + return current_version, err +} + +const getCurrentTagSourcesVersion = `-- name: GetCurrentTagSourcesVersion :one +SELECT current_version FROM tag_sources_meta WHERE id = true +` + +func (q *Queries) GetCurrentTagSourcesVersion(ctx context.Context) (int32, error) { + row := q.db.QueryRow(ctx, getCurrentTagSourcesVersion) + var current_version int32 + err := row.Scan(¤t_version) + return current_version, err +} + +const getTagProvidersHash = `-- name: GetTagProvidersHash :one +SELECT last_registered_providers_hash + FROM tag_sources_meta + WHERE id = true +` + +func (q *Queries) GetTagProvidersHash(ctx context.Context) (string, error) { + row := q.db.QueryRow(ctx, getTagProvidersHash) + var last_registered_providers_hash string + err := row.Scan(&last_registered_providers_hash) + return last_registered_providers_hash, err +} + +const listTagProviderSettings = `-- name: ListTagProviderSettings :many + +SELECT provider_id, enabled, api_key, display_order, created_at, updated_at + FROM tag_provider_settings + ORDER BY display_order, provider_id +` + +// Tag-enrichment provider settings queries (milestone #160, #1490 Step 2). +// The tags.SettingsService consumes these — parallel to the cover-art +// SettingsService (coverart_settings.sql). Keeping the two independent +// means a new tag source is added without touching the art settings. +// All rows, for boot reconciliation and the admin GET handler. +func (q *Queries) ListTagProviderSettings(ctx context.Context) ([]TagProviderSetting, error) { + rows, err := q.db.Query(ctx, listTagProviderSettings) + if err != nil { + return nil, err + } + defer rows.Close() + var items []TagProviderSetting + for rows.Next() { + var i TagProviderSetting + if err := rows.Scan( + &i.ProviderID, + &i.Enabled, + &i.ApiKey, + &i.DisplayOrder, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateTagProviderSettings = `-- name: UpdateTagProviderSettings :exec +UPDATE tag_provider_settings + SET enabled = COALESCE($2, enabled), + api_key = CASE WHEN $3::boolean THEN $4 ELSE api_key END, + updated_at = now() + WHERE provider_id = $1 +` + +type UpdateTagProviderSettingsParams struct { + ProviderID string + Enabled bool + Column3 bool + ApiKey *string +} + +// Admin PATCH. $2 sets enabled. Pass FALSE for the $3 apiKeyChanged flag +// to leave api_key untouched; TRUE with empty string clears it, TRUE with +// a value sets it. +func (q *Queries) UpdateTagProviderSettings(ctx context.Context, arg UpdateTagProviderSettingsParams) error { + _, err := q.db.Exec(ctx, updateTagProviderSettings, + arg.ProviderID, + arg.Enabled, + arg.Column3, + arg.ApiKey, + ) + return err +} + +const upsertTagProviderSettings = `-- name: UpsertTagProviderSettings :exec +INSERT INTO tag_provider_settings (provider_id, enabled, display_order) +VALUES ($1, $2, $3) +ON CONFLICT (provider_id) DO UPDATE + SET display_order = EXCLUDED.display_order, + updated_at = now() +` + +type UpsertTagProviderSettingsParams struct { + ProviderID string + Enabled bool + DisplayOrder int32 +} + +// INSERT a default row for a newly-registered provider, or refresh an +// existing row's display_order on boot. Does NOT overwrite enabled / +// api_key on conflict — the operator's settings persist across restarts. +func (q *Queries) UpsertTagProviderSettings(ctx context.Context, arg UpsertTagProviderSettingsParams) error { + _, err := q.db.Exec(ctx, upsertTagProviderSettings, arg.ProviderID, arg.Enabled, arg.DisplayOrder) + return err +} diff --git a/internal/db/dbq/taste_profile.sql.go b/internal/db/dbq/taste_profile.sql.go index db865aab..473af444 100644 --- a/internal/db/dbq/taste_profile.sql.go +++ b/internal/db/dbq/taste_profile.sql.go @@ -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 diff --git a/internal/db/dbq/track_tags.sql.go b/internal/db/dbq/track_tags.sql.go new file mode 100644 index 00000000..70ddcec4 --- /dev/null +++ b/internal/db/dbq/track_tags.sql.go @@ -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 +} diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index ffe498e3..ff34e172 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -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 } diff --git a/internal/db/migrations/0041_discover_taste_unheard.down.sql b/internal/db/migrations/0041_discover_taste_unheard.down.sql new file mode 100644 index 00000000..2bc620a2 --- /dev/null +++ b/internal/db/migrations/0041_discover_taste_unheard.down.sql @@ -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' + )); diff --git a/internal/db/migrations/0041_discover_taste_unheard.up.sql b/internal/db/migrations/0041_discover_taste_unheard.up.sql new file mode 100644 index 00000000..c2cee8bd --- /dev/null +++ b/internal/db/migrations/0041_discover_taste_unheard.up.sql @@ -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' + )); diff --git a/internal/db/migrations/0042_track_tags.down.sql b/internal/db/migrations/0042_track_tags.down.sql new file mode 100644 index 00000000..e60b8a3d --- /dev/null +++ b/internal/db/migrations/0042_track_tags.down.sql @@ -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; diff --git a/internal/db/migrations/0042_track_tags.up.sql b/internal/db/migrations/0042_track_tags.up.sql new file mode 100644 index 00000000..212da925 --- /dev/null +++ b/internal/db/migrations/0042_track_tags.up.sql @@ -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); diff --git a/internal/db/migrations/0043_tag_provider_settings.down.sql b/internal/db/migrations/0043_tag_provider_settings.down.sql new file mode 100644 index 00000000..6c82f9d0 --- /dev/null +++ b/internal/db/migrations/0043_tag_provider_settings.down.sql @@ -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; diff --git a/internal/db/migrations/0043_tag_provider_settings.up.sql b/internal/db/migrations/0043_tag_provider_settings.up.sql new file mode 100644 index 00000000..a9438bf1 --- /dev/null +++ b/internal/db/migrations/0043_tag_provider_settings.up.sql @@ -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); diff --git a/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.down.sql b/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.down.sql new file mode 100644 index 00000000..4ad01726 --- /dev/null +++ b/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.down.sql @@ -0,0 +1,2 @@ +-- Reverse 0044_taste_tuning_enriched_tag_scale.up.sql. +ALTER TABLE taste_tuning DROP COLUMN IF EXISTS enriched_tag_scale; diff --git a/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.up.sql b/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.up.sql new file mode 100644 index 00000000..4b605c32 --- /dev/null +++ b/internal/db/migrations/0044_taste_tuning_enriched_tag_scale.up.sql @@ -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; diff --git a/internal/db/migrations/0045_taste_profile_eras.down.sql b/internal/db/migrations/0045_taste_profile_eras.down.sql new file mode 100644 index 00000000..bbd08459 --- /dev/null +++ b/internal/db/migrations/0045_taste_profile_eras.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE taste_tuning DROP COLUMN IF EXISTS era_scale; +DROP TABLE IF EXISTS taste_profile_eras; diff --git a/internal/db/migrations/0045_taste_profile_eras.up.sql b/internal/db/migrations/0045_taste_profile_eras.up.sql new file mode 100644 index 00000000..54acd866 --- /dev/null +++ b/internal/db/migrations/0045_taste_profile_eras.up.sql @@ -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; diff --git a/internal/db/migrations/0046_context_time_weight.down.sql b/internal/db/migrations/0046_context_time_weight.down.sql new file mode 100644 index 00000000..d2be5249 --- /dev/null +++ b/internal/db/migrations/0046_context_time_weight.down.sql @@ -0,0 +1 @@ +ALTER TABLE recommendation_weight_profiles DROP COLUMN IF EXISTS context_time_weight; diff --git a/internal/db/migrations/0046_context_time_weight.up.sql b/internal/db/migrations/0046_context_time_weight.up.sql new file mode 100644 index 00000000..6b7b7388 --- /dev/null +++ b/internal/db/migrations/0046_context_time_weight.up.sql @@ -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; diff --git a/internal/db/migrations/0047_taste_profile_moods.down.sql b/internal/db/migrations/0047_taste_profile_moods.down.sql new file mode 100644 index 00000000..ae61a7dd --- /dev/null +++ b/internal/db/migrations/0047_taste_profile_moods.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE taste_tuning DROP COLUMN IF EXISTS mood_scale; +DROP TABLE IF EXISTS taste_profile_moods; diff --git a/internal/db/migrations/0047_taste_profile_moods.up.sql b/internal/db/migrations/0047_taste_profile_moods.up.sql new file mode 100644 index 00000000..3d16c380 --- /dev/null +++ b/internal/db/migrations/0047_taste_profile_moods.up.sql @@ -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; diff --git a/internal/db/migrations/0048_play_events_device_class.down.sql b/internal/db/migrations/0048_play_events_device_class.down.sql new file mode 100644 index 00000000..4742e15a --- /dev/null +++ b/internal/db/migrations/0048_play_events_device_class.down.sql @@ -0,0 +1 @@ +ALTER TABLE play_events DROP COLUMN IF EXISTS device_class; diff --git a/internal/db/migrations/0048_play_events_device_class.up.sql b/internal/db/migrations/0048_play_events_device_class.up.sql new file mode 100644 index 00000000..d8072d25 --- /dev/null +++ b/internal/db/migrations/0048_play_events_device_class.up.sql @@ -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; diff --git a/internal/db/queries/coplay.sql b/internal/db/queries/coplay.sql new file mode 100644 index 00000000..d2e07c0a --- /dev/null +++ b/internal/db/queries/coplay.sql @@ -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 +-- artist–artist co-occurrence edges from play_events and store them in +-- artist_similarity under the pre-provisioned source = 'user_cooccurrence' +-- (whitelisted in the 0009 CHECK from day one). A periodic worker DELETEs then +-- re-INSERTs so edges that fall below threshold disappear. Single-user servers +-- produce no edges (the >= 2 co-player gate), so the arm is empty there. + +-- 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; diff --git a/internal/db/queries/discover.sql b/internal/db/queries/discover.sql index 3f3762d4..ed44c378 100644 --- a/internal/db/queries/discover.sql +++ b/internal/db/queries/discover.sql @@ -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; diff --git a/internal/db/queries/events.sql b/internal/db/queries/events.sql index 565b510c..2be5df2f 100644 --- a/internal/db/queries/events.sql +++ b/internal/db/queries/events.sql @@ -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 diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 05882dbe..bf9e10f7 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -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 6–7 (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 diff --git a/internal/db/queries/recommendation_tuning.sql b/internal/db/queries/recommendation_tuning.sql index 760ea716..b017e542 100644 --- a/internal/db/queries/recommendation_tuning.sql +++ b/internal/db/queries/recommendation_tuning.sql @@ -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 *; diff --git a/internal/db/queries/system_playlists.sql b/internal/db/queries/system_playlists.sql index 4f8a7696..b57fd079 100644 --- a/internal/db/queries/system_playlists.sql +++ b/internal/db/queries/system_playlists.sql @@ -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: diff --git a/internal/db/queries/tag_settings.sql b/internal/db/queries/tag_settings.sql new file mode 100644 index 00000000..03748656 --- /dev/null +++ b/internal/db/queries/tag_settings.sql @@ -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; diff --git a/internal/db/queries/taste_profile.sql b/internal/db/queries/taste_profile.sql index 01387670..d9ddb987 100644 --- a/internal/db/queries/taste_profile.sql +++ b/internal/db/queries/taste_profile.sql @@ -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; diff --git a/internal/db/queries/track_tags.sql b/internal/db/queries/track_tags.sql new file mode 100644 index 00000000..9449265a --- /dev/null +++ b/internal/db/queries/track_tags.sql @@ -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[]); diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index b8c1465a..b499dfeb 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -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) + } } diff --git a/internal/mood/mood.go b/internal/mood/mood.go new file mode 100644 index 00000000..7e855643 --- /dev/null +++ b/internal/mood/mood.go @@ -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 +} diff --git a/internal/mood/mood_test.go b/internal/mood/mood_test.go new file mode 100644 index 00000000..3be30e01 --- /dev/null +++ b/internal/mood/mood_test.go @@ -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) + } +} diff --git a/internal/playevents/writer.go b/internal/playevents/writer.go index 22999945..f83fb099 100644 --- a/internal/playevents/writer.go +++ b/internal/playevents/writer.go @@ -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 diff --git a/internal/playevents/writer_test.go b/internal/playevents/writer_test.go index b6dc1bd6..bab96826 100644 --- a/internal/playevents/writer_test.go +++ b/internal/playevents/writer_test.go @@ -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) } diff --git a/internal/playlists/discover.go b/internal/playlists/discover.go index 809e33fb..f70c658d 100644 --- a/internal/playlists/discover.go +++ b/internal/playlists/discover.go @@ -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 { diff --git a/internal/playlists/seed_selection_test.go b/internal/playlists/seed_selection_test.go index e3399f3b..14822422 100644 --- a/internal/playlists/seed_selection_test.go +++ b/internal/playlists/seed_selection_test.go @@ -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}, diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 5103c14f..d12a1e3e 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -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")). // diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index 81c9f6bf..ad6531e4 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -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 diff --git a/internal/recommendation/context.go b/internal/recommendation/context.go new file mode 100644 index 00000000..6f94345d --- /dev/null +++ b/internal/recommendation/context.go @@ -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) +} diff --git a/internal/recommendation/context_device_integration_test.go b/internal/recommendation/context_device_integration_test.go new file mode 100644 index 00000000..484c5d62 --- /dev/null +++ b/internal/recommendation/context_device_integration_test.go @@ -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)) + } +} diff --git a/internal/recommendation/context_test.go b/internal/recommendation/context_test.go new file mode 100644 index 00000000..56437b6d --- /dev/null +++ b/internal/recommendation/context_test.go @@ -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) + } +} diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go index c3b9a9ef..3876519b 100644 --- a/internal/recommendation/score.go +++ b/internal/recommendation/score.go @@ -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 } diff --git a/internal/recommendation/sessionvector.go b/internal/recommendation/sessionvector.go index d244d51e..c98a7d13 100644 --- a/internal/recommendation/sessionvector.go +++ b/internal/recommendation/sessionvector.go @@ -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 { diff --git a/internal/recommendation/taste.go b/internal/recommendation/taste.go index 0953b01c..46fd37f6 100644 --- a/internal/recommendation/taste.go +++ b/internal/recommendation/taste.go @@ -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]. diff --git a/internal/recommendation/taste_test.go b/internal/recommendation/taste_test.go index 408e894a..3dfd97e8 100644 --- a/internal/recommendation/taste_test.go +++ b/internal/recommendation/taste_test.go @@ -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) } } diff --git a/internal/recsettings/patch.go b/internal/recsettings/patch.go index e3c78ab6..6ae86833 100644 --- a/internal/recsettings/patch.go +++ b/internal/recsettings/patch.go @@ -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 } diff --git a/internal/recsettings/service.go b/internal/recsettings/service.go index 4a1a0b0b..22640847 100644 --- a/internal/recsettings/service.go +++ b/internal/recsettings/service.go @@ -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, } } diff --git a/internal/recsettings/service_test.go b/internal/recsettings/service_test.go index 65019ff3..cf96f8a3 100644 --- a/internal/recsettings/service_test.go +++ b/internal/recsettings/service_test.go @@ -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) diff --git a/internal/server/server.go b/internal/server/server.go index 05b3cb65..4b75e7b4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 diff --git a/internal/tags/enricher.go b/internal/tags/enricher.go new file mode 100644 index 00000000..60b9909f --- /dev/null +++ b/internal/tags/enricher.go @@ -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 "" + } + 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]) +} diff --git a/internal/tags/enricher_test.go b/internal/tags/enricher_test.go new file mode 100644 index 00000000..844cf92f --- /dev/null +++ b/internal/tags/enricher_test.go @@ -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) + } +} diff --git a/internal/tags/httpclient.go b/internal/tags/httpclient.go new file mode 100644 index 00000000..a65f1087 --- /dev/null +++ b/internal/tags/httpclient.go @@ -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< 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) +) diff --git a/internal/tags/provider_lastfm_test.go b/internal/tags/provider_lastfm_test.go new file mode 100644 index 00000000..675e86ed --- /dev/null +++ b/internal/tags/provider_lastfm_test.go @@ -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) + } + }) + } +} diff --git a/internal/tags/provider_musicbrainz.go b/internal/tags/provider_musicbrainz.go new file mode 100644 index 00000000..5c89e7d9 --- /dev/null +++ b/internal/tags/provider_musicbrainz.go @@ -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) +) diff --git a/internal/tags/provider_musicbrainz_test.go b/internal/tags/provider_musicbrainz_test.go new file mode 100644 index 00000000..8af8774c --- /dev/null +++ b/internal/tags/provider_musicbrainz_test.go @@ -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) + } +} diff --git a/internal/tags/provider_test.go b/internal/tags/provider_test.go new file mode 100644 index 00000000..fec124e8 --- /dev/null +++ b/internal/tags/provider_test.go @@ -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") + } +} diff --git a/internal/tags/settings.go b/internal/tags/settings.go new file mode 100644 index 00000000..b16a3117 --- /dev/null +++ b/internal/tags/settings.go @@ -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 +} diff --git a/internal/tags/worker.go b/internal/tags/worker.go new file mode 100644 index 00000000..701a08f3 --- /dev/null +++ b/internal/tags/worker.go @@ -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) + } + } +} diff --git a/internal/taste/profile.go b/internal/taste/profile.go index c9696bdf..65fd187a 100644 --- a/internal/taste/profile.go +++ b/internal/taste/profile.go @@ -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) } diff --git a/internal/taste/profile_test.go b/internal/taste/profile_test.go index f3a9e472..538601f7 100644 --- a/internal/taste/profile_test.go +++ b/internal/taste/profile_test.go @@ -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) diff --git a/web/src/lib/api/admin.tag-sources.test.ts b/web/src/lib/api/admin.tag-sources.test.ts new file mode 100644 index 00000000..e7b9c4d2 --- /dev/null +++ b/web/src/lib/api/admin.tag-sources.test.ts @@ -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).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).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).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).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'); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 8e4d775a..8fafa7a8 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -383,6 +383,67 @@ export function createCoverProvidersQuery() { }); } +// Tag-enrichment providers ------------------------------------------------- +// Parallel to the cover-art providers surface, over /api/admin/tag-sources. +// Independent settings so a new folksonomy source is added without touching +// art config (#1490). + +export type TagProviderCapability = 'track_tags'; + +export type TagProvider = { + id: string; + display_name: string; + requires_api_key: boolean; + supports: TagProviderCapability[]; + enabled: boolean; + api_key_set: boolean; + display_order: number; + testable: boolean; +}; + +export type TagProvidersResponse = { + providers: TagProvider[]; + sources_version: number; +}; + +export async function getTagProviders(): Promise { + return api.get('/api/admin/tag-sources'); +} + +export type UpdateTagProviderPatch = { + enabled?: boolean; + api_key?: string; +}; + +export type UpdateTagProviderResponse = TagProvider & { + version_bumped: boolean; +}; + +export async function updateTagProvider( + id: string, + patch: UpdateTagProviderPatch +): Promise { + return api.patch(`/api/admin/tag-sources/${id}`, patch); +} + +export type TestTagProviderResponse = { + ok: boolean; + duration_ms?: number; + error?: string; +}; + +export async function testTagProvider(id: string): Promise { + return api.post(`/api/admin/tag-sources/${id}/test`, {}); +} + +export function createTagProvidersQuery() { + return createQuery({ + queryKey: qk.tagProviders(), + queryFn: getTagProviders, + staleTime: 60_000 + }); +} + // Admin user-management ------------------------------------------------------ export type AdminUser = { diff --git a/web/src/lib/api/queries.ts b/web/src/lib/api/queries.ts index d12397e1..f59d7260 100644 --- a/web/src/lib/api/queries.ts +++ b/web/src/lib/api/queries.ts @@ -48,6 +48,7 @@ export const qk = { scanStatus: () => ['scanStatus'] as const, coverage: () => ['coverage'] as const, coverProviders: () => ['coverProviders'] as const, + tagProviders: () => ['tagProviders'] as const, adminUsers: () => ['adminUsers'] as const, adminInvites: () => ['adminInvites'] as const, adminDiagnostics: (f: Record) => diff --git a/web/src/lib/api/tuning.ts b/web/src/lib/api/tuning.ts index 8ffd0218..fae0f40f 100644 --- a/web/src/lib/api/tuning.ts +++ b/web/src/lib/api/tuning.ts @@ -13,6 +13,7 @@ export type WeightProfile = { context_weight: number; similarity_weight: number; taste_weight: number; + context_time_weight: number; }; export type TasteTuning = { @@ -20,6 +21,9 @@ export type TasteTuning = { engagement_hard_skip: number; engagement_neutral: number; engagement_full: number; + enriched_tag_scale: number; + era_scale: number; + mood_scale: number; }; export type TuningScope = 'radio' | 'daily_mix' | 'taste'; diff --git a/web/src/lib/player/events.svelte.ts b/web/src/lib/player/events.svelte.ts index 6606d648..4240c26b 100644 --- a/web/src/lib/player/events.svelte.ts +++ b/web/src/lib/player/events.svelte.ts @@ -124,7 +124,10 @@ export function useEventsDispatcher(): void { // #415: tag the play with the system playlist it came from // (null for library / user-playlist / radio) so the server // can advance that playlist's rotation. - source: player.queueSource ?? undefined + source: player.queueSource ?? undefined, + // #1551: device class for context conditioning. The web app is a + // browser session, so it reports 'web'. + device_class: 'web' }); // The user may have moved on by the time the response arrives. Only // adopt the id if we're still on the same track and still playing. diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 5f711c2b..266e8182 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -43,10 +43,11 @@ (systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'discover') ?? null ); + // Songs-like mixes get their own dedicated row (#1491) — no longer + // capped to 3 carousel slots, so take all the server generated. const songsLikePlaylists = $derived( (systemPlaylistsQ.data?.owned ?? []) .filter((p) => p.system_variant === 'songs_like_artist') - .slice(0, 3) ); // Secondary system kinds the server generates that don't get pinned @@ -108,14 +109,7 @@ out.push({ kind: 'placeholder', label: 'Discover', variant: placeholderVariant('discover') }); } - // Slots 3-5: Songs-like (real first, padded with placeholders). - for (let i = 0; i < 3; i++) { - if (songsLikePlaylists[i]) { - out.push({ kind: 'real', playlist: songsLikePlaylists[i] }); - } else { - out.push({ kind: 'placeholder', label: 'Songs like…', variant: placeholderVariant('songs-like') }); - } - } + // Songs-like moved to its own dedicated row below (#1491). // Secondary system kinds in server-registry order, when generated. for (const p of secondarySystemPlaylists) { @@ -129,6 +123,25 @@ return out; }); + + // Dedicated Songs-like row (#1491): every generated "Songs like {artist}" + // mix — the best-performing surface, promoted out of the Playlists + // carousel to show a wider spread. Shows a few placeholders while the + // mixes haven't generated yet (building / seed-needed). + const SONGS_LIKE_PLACEHOLDER_SLOTS = 3; + const songsLikeRow = $derived.by((): PlaylistRowItem[] => { + if (songsLikePlaylists.length > 0) { + return songsLikePlaylists.map((playlist) => ({ kind: 'real', playlist }) as PlaylistRowItem); + } + return Array.from( + { length: SONGS_LIKE_PLACEHOLDER_SLOTS }, + (): PlaylistRowItem => ({ + kind: 'placeholder', + label: 'Songs like…', + variant: placeholderVariant('songs-like') + }) + ); + }); {pageTitle('Home')} @@ -156,6 +169,27 @@ + +
+ + {#snippet item(rowItem: PlaylistRowItem)} +
+ {#if rowItem.kind === 'real'} + + {:else} + + {/if} +
+ {/snippet} +
+
+ {#if query.isError} {:else if showSkeleton.value && !data} diff --git a/web/src/routes/admin/integrations/+page.svelte b/web/src/routes/admin/integrations/+page.svelte index 1b5b7553..7915ec50 100644 --- a/web/src/routes/admin/integrations/+page.svelte +++ b/web/src/routes/admin/integrations/+page.svelte @@ -13,10 +13,14 @@ createCoverProvidersQuery, updateCoverProvider, testCoverProvider, + createTagProvidersQuery, + updateTagProvider, + testTagProvider, updateSMTPConfig, testSMTPConfig, createSMTPConfigQuery, type CoverProvider, + type TagProvider, type SMTPConfig } from '$lib/api/admin'; import { qk } from '$lib/api/queries'; @@ -280,6 +284,62 @@ } } + // ---- Tag enrichment providers ---- + // Mirrors the cover-art providers panel over /api/admin/tag-sources (#1490). + const tagProvidersStore = $derived(createTagProvidersQuery()); + const tagProvidersQ = $derived($tagProvidersStore); + + let tagLocalState = $state>({}); + let tagTestResults = $state>({}); + let tagSaving = $state>({}); + let tagTesting = $state>({}); + + $effect(() => { + if (tagProvidersQ.data) { + for (const p of tagProvidersQ.data.providers) { + if (!tagLocalState[p.id]) { + tagLocalState[p.id] = { enabled: p.enabled, apiKey: '' }; + } + } + } + }); + + function tagHasChanges(provider: TagProvider): boolean { + const local = tagLocalState[provider.id]; + if (!local) return false; + return provider.enabled !== local.enabled || local.apiKey !== ''; + } + + async function tagSave(provider: TagProvider) { + const local = tagLocalState[provider.id]; + if (!local || !tagHasChanges(provider)) return; + const patch: { enabled?: boolean; api_key?: string } = {}; + if (provider.enabled !== local.enabled) patch.enabled = local.enabled; + if (local.apiKey !== '') patch.api_key = local.apiKey; + + tagSaving[provider.id] = true; + try { + const result = await updateTagProvider(provider.id, patch); + tagLocalState[provider.id] = { enabled: result.enabled, apiKey: '' }; + await client.invalidateQueries({ queryKey: qk.tagProviders() }); + } catch (_e) { + // Inline error surfacing handled by the mutation toast layer; swallow here. + } finally { + tagSaving[provider.id] = false; + } + } + + async function tagTest(provider: TagProvider) { + tagTesting[provider.id] = true; + try { + tagTestResults[provider.id] = await testTagProvider(provider.id); + } catch (e) { + tagTestResults[provider.id] = { ok: false, error: (e as Error).message }; + } finally { + tagTesting[provider.id] = false; + } + } + // SMTP config ------------------------------------------------------------- const smtpStore = $derived(createSMTPConfigQuery()); @@ -597,6 +657,108 @@

No providers registered.

{/if} + + +
+
+

Tag enrichment sources

+

+ Folksonomy style/mood tags fetched per track and folded into each + listener's taste profile alongside the raw file genre — so "Rock" + gains "post-punk / shoegaze / melancholic". All enabled sources are + merged. Enabling or disabling a source re-opens previously-processed + tracks for a fresh pass. +

+
+ + {#if tagProvidersQ.data} + {#each tagProvidersQ.data.providers as provider (provider.id)} +
+
+
+

{provider.display_name}

+ +
+
+ {#each provider.supports as cap} + {cap.replace('_', ' ')} + {/each} +
+
+ + {#if tagLocalState[provider.id]} +
+ +
+ +
+ + {#if provider.requires_api_key} + +
+ + {#if provider.api_key_set} + ✓ Set + {/if} +
+ {/if} +
+ +
+ + {#if provider.testable} + + {/if} + {#if tagTestResults[provider.id]} + {#if tagTestResults[provider.id].ok} +

+ OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''} +

+ {:else} +

+ Failed — {tagTestResults[provider.id].error} +

+ {/if} + {/if} +
+ {/if} + + {#if provider.id === 'musicbrainz'} +

+ Keyless and on by default — the always-available baseline. Matches tags by + recording MBID, so coverage tracks how well your library is MusicBrainz-tagged. +

+ {:else if provider.id === 'lastfm'} +

+ Name-based (artist + title), so it covers tracks without an MBID. Requires a free + API key — get one at last.fm/api. +

+ {/if} +
+ {/each} + {:else if tagProvidersQ.isPending} +

Loading…

+ {:else} +

No providers registered.

+ {/if} +
diff --git a/web/src/routes/admin/integrations/integrations.test.ts b/web/src/routes/admin/integrations/integrations.test.ts index e5b295b4..360d41a3 100644 --- a/web/src/routes/admin/integrations/integrations.test.ts +++ b/web/src/routes/admin/integrations/integrations.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte'; import { mockQuery } from '../../../test-utils/query'; import type { LidarrConfig } from '$lib/api/types'; -import type { CoverProvider, SMTPConfig } from '$lib/api/admin'; +import type { CoverProvider, TagProvider, SMTPConfig } from '$lib/api/admin'; // useQueryClient is wrapped so the page can call invalidateQueries() without // a real QueryClient context. Per-file mock OVERRIDES the vitest.setup default @@ -23,6 +23,9 @@ vi.mock('$lib/api/admin', () => ({ createCoverProvidersQuery: vi.fn(), updateCoverProvider: vi.fn(), testCoverProvider: vi.fn(), + createTagProvidersQuery: vi.fn(), + updateTagProvider: vi.fn(), + testTagProvider: vi.fn(), createSMTPConfigQuery: vi.fn(), updateSMTPConfig: vi.fn(), testSMTPConfig: vi.fn() @@ -39,6 +42,9 @@ import { createCoverProvidersQuery, updateCoverProvider, testCoverProvider, + createTagProvidersQuery, + updateTagProvider, + testTagProvider, createSMTPConfigQuery, updateSMTPConfig, testSMTPConfig @@ -95,6 +101,34 @@ const defaultCoverProviders = { sources_version: 1 }; +// Fixture tag providers +const providerMusicbrainz: TagProvider = { + id: 'musicbrainz', + display_name: 'MusicBrainz', + requires_api_key: false, + supports: ['track_tags'], + enabled: true, + api_key_set: false, + display_order: 0, + testable: true +}; + +const providerLastfm: TagProvider = { + id: 'lastfm', + display_name: 'Last.fm', + requires_api_key: true, + supports: ['track_tags'], + enabled: false, + api_key_set: false, + display_order: 1, + testable: true +}; + +const defaultTagProviders = { + providers: [providerMusicbrainz, providerLastfm], + sources_version: 1 +}; + const testResponseOk = { ok: true as const, version: '2.0.5', @@ -133,7 +167,7 @@ afterEach(() => { invalidateQueries.mockReset(); }); -function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; smtp?: SMTPConfig } = {}) { +function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; tagProviders?: typeof defaultTagProviders | null; smtp?: SMTPConfig } = {}) { (createLidarrConfigQuery as ReturnType).mockReturnValue( mockQuery({ data: opts.config ?? cfgConnected }) ); @@ -160,6 +194,10 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov (createCoverProvidersQuery as ReturnType).mockReturnValue( mockQuery({ data: cpData, isPending: opts.coverProviders === null }) ); + const tpData = opts.tagProviders === null ? undefined : (opts.tagProviders ?? defaultTagProviders); + (createTagProvidersQuery as ReturnType).mockReturnValue( + mockQuery({ data: tpData, isPending: opts.tagProviders === null }) + ); (createSMTPConfigQuery as ReturnType).mockReturnValue( mockQuery({ data: opts.smtp ?? smtpUnset }) ); @@ -195,6 +233,16 @@ function providerCard(providerName: string): HTMLElement { .closest('article') as HTMLElement; } +function tagProvidersSection(): HTMLElement { + return screen.getByRole('heading', { name: /tag enrichment sources/i, level: 3 }).closest('section') as HTMLElement; +} + +function tagProviderCard(providerName: string): HTMLElement { + return within(tagProvidersSection()) + .getByRole('heading', { name: providerName, level: 4 }) + .closest('article') as HTMLElement; +} + describe('/admin/integrations', () => { test('Save calls putLidarrConfig with empty api_key when input is blank', async () => { setup(); @@ -641,3 +689,48 @@ describe('Lidarr first-time setup (Test-then-Save)', () => { }); }); }); + +describe('/admin/integrations tag sources', () => { + test('renders tag enrichment providers', () => { + setup(); + const section = tagProvidersSection(); + expect(within(section).getByRole('heading', { name: 'MusicBrainz', level: 4 })).toBeInTheDocument(); + expect(within(section).getByRole('heading', { name: 'Last.fm', level: 4 })).toBeInTheDocument(); + expect(within(section).getAllByText('track tags').length).toBeGreaterThanOrEqual(1); + }); + + test('enabling Last.fm and typing a key calls updateTagProvider', async () => { + setup(); + (updateTagProvider as ReturnType).mockResolvedValueOnce({ + ...providerLastfm, + enabled: true, + api_key_set: true, + version_bumped: true + }); + const card = tagProviderCard('Last.fm'); + const enabledCheckbox = card.querySelector('input[type="checkbox"]') as HTMLInputElement; + await fireEvent.click(enabledCheckbox); + const keyInput = within(card).getByLabelText(/api key/i); + await fireEvent.input(keyInput, { target: { value: 'lfmkey' } }); + await fireEvent.click(within(card).getByRole('button', { name: /save changes/i })); + + expect(updateTagProvider).toHaveBeenCalledWith( + 'lastfm', + expect.objectContaining({ enabled: true, api_key: 'lfmkey' }) + ); + await waitFor(() => + expect(invalidateQueries).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: ['tagProviders'] }) + ) + ); + }); + + test('Test connection on a tag provider calls testTagProvider and shows OK', async () => { + setup(); + (testTagProvider as ReturnType).mockResolvedValueOnce({ ok: true, duration_ms: 42 }); + const card = tagProviderCard('MusicBrainz'); + await fireEvent.click(within(card).getByRole('button', { name: /test connection/i })); + await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz')); + await waitFor(() => expect(within(card).getByText(/ok.*42ms/i)).toBeInTheDocument()); + }); +}); diff --git a/web/src/routes/admin/tuning/+page.svelte b/web/src/routes/admin/tuning/+page.svelte index ef7a9d7a..79e96668 100644 --- a/web/src/routes/admin/tuning/+page.svelte +++ b/web/src/routes/admin/tuning/+page.svelte @@ -29,14 +29,18 @@ { key: 'jitter_magnitude', label: 'Jitter', hint: 'Random reshuffle magnitude for near-ties.' }, { key: 'context_weight', label: 'Context weight', hint: 'Session-vector similarity contribution.' }, { key: 'similarity_weight', label: 'Similarity weight', hint: 'Seed-similarity contribution.' }, - { key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' } + { key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' }, + { key: 'context_time_weight', label: 'Time-of-day weight', hint: "Artist's time-of-day/weekday affinity for the current context, in [-1, +1]. 0 = ignore when you listen." } ]; const tasteFields: { key: keyof TasteTuning; label: string; hint: string }[] = [ { key: 'half_life_days', label: 'Half-life (days)', hint: "A play's influence halves every this-many days." }, { key: 'engagement_hard_skip', label: 'Hard-skip point', hint: 'Completion at/below which a play reads −1.' }, { key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' }, - { key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' } + { key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' }, + { key: 'enriched_tag_scale', label: 'Enriched tag weight', hint: 'How much folksonomy tags (MusicBrainz/Last.fm) count vs raw file genre, in [0, 1]. 0 = genre only.' }, + { key: 'era_scale', label: 'Era weight', hint: 'How strongly a decade-play imprints on the era facet, in [0, 1]. 0 = era ignored.' }, + { key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' } ]; const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [ diff --git a/web/src/routes/admin/tuning/tuning.test.ts b/web/src/routes/admin/tuning/tuning.test.ts index d953d164..ce0de18d 100644 --- a/web/src/routes/admin/tuning/tuning.test.ts +++ b/web/src/routes/admin/tuning/tuning.test.ts @@ -28,6 +28,7 @@ const weights = (over: Partial> = {}) => ({ context_weight: 0.5, similarity_weight: 1.5, taste_weight: 1.5, + context_time_weight: 1, ...over }); @@ -36,6 +37,9 @@ const taste = (over: Partial> = {}) => ({ engagement_hard_skip: 0.05, engagement_neutral: 0.3, engagement_full: 0.9, + enriched_tag_scale: 0.5, + era_scale: 0.5, + mood_scale: 0.5, ...over }); diff --git a/web/src/routes/page.test.ts b/web/src/routes/page.test.ts index b8a6bd17..cb11e9e2 100644 --- a/web/src/routes/page.test.ts +++ b/web/src/routes/page.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { render, screen } from '@testing-library/svelte'; +import { render, screen, within } from '@testing-library/svelte'; import { readable } from 'svelte/store'; import { emptyLikesMock } from '../test-utils/mocks/likes'; import type { Playlist } from '$lib/api/types'; @@ -57,7 +57,8 @@ afterEach(() => vi.clearAllMocks()); describe('home Playlists section', () => { test('renders 5 placeholder cards when no playlists exist', () => { - // Slot layout: For-You, Discover, 3× Songs-like. + // For-You + Discover in the Playlists row, plus 3 in the dedicated + // Songs-like row (#1491) = 5 placeholders total. render(Page); const placeholders = screen.queryAllByTestId('playlist-placeholder-card'); expect(placeholders).toHaveLength(5); @@ -107,12 +108,52 @@ describe('home Playlists section', () => { render(Page); expect(screen.getByText('For You')).toBeInTheDocument(); const placeholders = screen.queryAllByTestId('playlist-placeholder-card'); - // 1 Discover + 3 Songs-like slots = 4 placeholders alongside the - // real For-You tile. + // 1 Discover placeholder (Playlists row) + 3 Songs-like placeholders + // (dedicated row) = 4 alongside the real For-You tile. expect(placeholders).toHaveLength(4); }); - test('renders secondary system kinds (deep_cuts / new_for_you) after Songs-like slots', () => { + test('dedicated Songs-like row shows every generated mix, uncapped and out of Playlists', () => { + const makeSongsLike = (id: string, name: string): Playlist => ({ + id, + user_id: 'u1', + owner_username: 'u1', + name, + description: '', + is_public: false, + kind: 'system', + system_variant: 'songs_like_artist', + refreshable: false, + seed_artist_id: 'a1', + cover_url: '', + track_count: 25, + duration_sec: 1500, + created_at: '2026-05-04T00:00:00Z', + updated_at: '2026-05-04T00:00:00Z' + }); + // Six generated mixes — the old carousel capped at 3; the dedicated row shows all. + const owned = Array.from({ length: 6 }, (_, i) => makeSongsLike(`sl${i}`, `Songs like ${i}`)); + (createPlaylistsQuery as unknown as ReturnType).mockImplementation( + (kind?: string) => + kind === 'system' + ? readable({ data: { owned, public: [] }, isPending: false, isError: false }) + : readable({ data: emptyPlaylistsResponse, isPending: false, isError: false }) + ); + const { container } = render(Page); + + for (let i = 0; i < 6; i++) { + expect(screen.getByText(`Songs like ${i}`)).toBeInTheDocument(); + } + // The mixes live in the Songs-like row, not the Playlists carousel. + const playlistsSection = container.querySelector('[aria-label="Playlists"]') as HTMLElement; + expect(within(playlistsSection).queryByText('Songs like 0')).toBeNull(); + const songsSection = container.querySelector('[aria-label="Songs like"]') as HTMLElement; + expect(within(songsSection).getByText('Songs like 0')).toBeInTheDocument(); + // No placeholders once real mixes exist. + expect(within(songsSection).queryByTestId('playlist-placeholder-card')).toBeNull(); + }); + + test('renders secondary system kinds (deep_cuts / new_for_you) in the Playlists row', () => { // Operator backflow 2026-06-01: web Home surfaces the 5 secondary // system kinds when generated. No placeholders for them — they // depend on library shape, so missing means "not enough data."