Taste-profile fidelity (M160) + Songs-like row + home polish #109
+48
@@ -0,0 +1,48 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.data
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.AdminTagSourcesApi
|
||||||
|
import com.fabledsword.minstrel.api.endpoints.UpdateTagSourceBody
|
||||||
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
|
import com.fabledsword.minstrel.models.wire.AdminTagSourceWire
|
||||||
|
import com.fabledsword.minstrel.models.wire.TestTagSourceWire
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.create
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-through accessor for the tag-enrichment provider settings (#1521).
|
||||||
|
* No Room caching — admin settings are infrequent point-and-shoot edits;
|
||||||
|
* mutations fire direct REST and the ViewModel reconciles on failure.
|
||||||
|
* Exceptions propagate to the ViewModel (which maps them via ErrorCopy).
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class AdminTagSourcesRepository @Inject constructor(
|
||||||
|
retrofit: Retrofit,
|
||||||
|
) {
|
||||||
|
private val api: AdminTagSourcesApi = retrofit.create()
|
||||||
|
|
||||||
|
suspend fun list(): List<AdminTagSourceRef> = api.list().providers.map { it.toDomain() }
|
||||||
|
|
||||||
|
suspend fun setEnabled(id: String, enabled: Boolean): AdminTagSourceRef =
|
||||||
|
api.update(id, UpdateTagSourceBody(enabled = enabled)).toDomain()
|
||||||
|
|
||||||
|
suspend fun setApiKey(id: String, apiKey: String): AdminTagSourceRef =
|
||||||
|
api.update(id, UpdateTagSourceBody(apiKey = apiKey)).toDomain()
|
||||||
|
|
||||||
|
suspend fun test(id: String): TagSourceTestResult = api.test(id).toResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun AdminTagSourceWire.toDomain(): AdminTagSourceRef = AdminTagSourceRef(
|
||||||
|
id = id,
|
||||||
|
displayName = displayName,
|
||||||
|
requiresApiKey = requiresApiKey,
|
||||||
|
supports = supports,
|
||||||
|
enabled = enabled,
|
||||||
|
apiKeySet = apiKeySet,
|
||||||
|
testable = testable,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun TestTagSourceWire.toResult(): TagSourceTestResult =
|
||||||
|
TagSourceTestResult(ok = ok, durationMs = durationMs, error = error)
|
||||||
@@ -28,15 +28,18 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import com.composables.icons.lucide.Inbox
|
import com.composables.icons.lucide.Inbox
|
||||||
import com.composables.icons.lucide.Lucide
|
import com.composables.icons.lucide.Lucide
|
||||||
|
import com.composables.icons.lucide.Music
|
||||||
import com.composables.icons.lucide.TriangleAlert
|
import com.composables.icons.lucide.TriangleAlert
|
||||||
import com.composables.icons.lucide.Users
|
import com.composables.icons.lucide.Users
|
||||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
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.admin.data.AdminUsersRepository
|
||||||
import com.fabledsword.minstrel.api.ErrorCopy
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
import com.fabledsword.minstrel.nav.Admin
|
import com.fabledsword.minstrel.nav.Admin
|
||||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||||
import com.fabledsword.minstrel.nav.AdminRequests
|
import com.fabledsword.minstrel.nav.AdminRequests
|
||||||
|
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||||
import com.fabledsword.minstrel.nav.AdminUsers
|
import com.fabledsword.minstrel.nav.AdminUsers
|
||||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
@@ -54,7 +57,7 @@ import javax.inject.Inject
|
|||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── 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 {
|
sealed interface AdminLandingUiState {
|
||||||
data object Loading : AdminLandingUiState
|
data object Loading : AdminLandingUiState
|
||||||
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
private val requestsRepo: AdminRequestsRepository,
|
private val requestsRepo: AdminRequestsRepository,
|
||||||
private val quarantineRepo: AdminQuarantineRepository,
|
private val quarantineRepo: AdminQuarantineRepository,
|
||||||
private val usersRepo: AdminUsersRepository,
|
private val usersRepo: AdminUsersRepository,
|
||||||
|
private val tagSourcesRepo: AdminTagSourcesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||||
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
|
|||||||
val req = async { requestsRepo.list().size }
|
val req = async { requestsRepo.list().size }
|
||||||
val qua = async { quarantineRepo.list().size }
|
val qua = async { quarantineRepo.list().size }
|
||||||
val usr = async { usersRepo.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)
|
internal.value = AdminLandingUiState.Success(counts)
|
||||||
} catch (
|
} catch (
|
||||||
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
|||||||
onClick = { navController.navigate(AdminUsers) },
|
onClick = { navController.navigate(AdminUsers) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
SectionCard(
|
||||||
|
icon = Lucide.Music,
|
||||||
|
title = "Tag sources",
|
||||||
|
subtitle = "Metadata enrichment providers",
|
||||||
|
count = counts.tagSources,
|
||||||
|
onClick = { navController.navigate(AdminTagSources) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ElevatedCard
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
|
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||||
|
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AdminTagSourcesScreen(
|
||||||
|
navController: NavHostController,
|
||||||
|
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
Scaffold(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
MinstrelTopAppBar(
|
||||||
|
title = "Admin · Tag sources",
|
||||||
|
navController = navController,
|
||||||
|
currentRouteName = AdminTagSources::class.qualifiedName,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { inner ->
|
||||||
|
PullToRefreshScaffold(
|
||||||
|
onRefresh = { viewModel.refresh().join() },
|
||||||
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
|
) {
|
||||||
|
when (val s = state) {
|
||||||
|
AdminTagSourcesUiState.Loading -> LoadingCentered()
|
||||||
|
AdminTagSourcesUiState.Empty -> EmptyState(
|
||||||
|
title = "No tag sources",
|
||||||
|
body = "Tag-enrichment providers register on the server; none are available.",
|
||||||
|
)
|
||||||
|
is AdminTagSourcesUiState.Error -> ErrorRetry(
|
||||||
|
title = "Couldn't load tag sources",
|
||||||
|
message = s.message,
|
||||||
|
onRetry = { viewModel.refresh() },
|
||||||
|
)
|
||||||
|
is AdminTagSourcesUiState.Success -> TagSourceList(
|
||||||
|
providers = s.providers,
|
||||||
|
testResults = s.testResults,
|
||||||
|
onToggle = viewModel::setEnabled,
|
||||||
|
onSaveKey = viewModel::saveApiKey,
|
||||||
|
onTest = viewModel::test,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TagSourceList(
|
||||||
|
providers: List<AdminTagSourceRef>,
|
||||||
|
testResults: Map<String, TagSourceTestResult>,
|
||||||
|
onToggle: (String, Boolean) -> Unit,
|
||||||
|
onSaveKey: (String, String) -> Unit,
|
||||||
|
onTest: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(items = providers, key = { it.id }) { provider ->
|
||||||
|
TagSourceCard(
|
||||||
|
provider = provider,
|
||||||
|
testResult = testResults[provider.id],
|
||||||
|
onToggle = onToggle,
|
||||||
|
onSaveKey = onSaveKey,
|
||||||
|
onTest = onTest,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TagSourceCard(
|
||||||
|
provider: AdminTagSourceRef,
|
||||||
|
testResult: TagSourceTestResult?,
|
||||||
|
onToggle: (String, Boolean) -> Unit,
|
||||||
|
onSaveKey: (String, String) -> Unit,
|
||||||
|
onTest: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(
|
||||||
|
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Switch(
|
||||||
|
checked = provider.enabled,
|
||||||
|
onCheckedChange = { onToggle(provider.id, it) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (provider.requiresApiKey) {
|
||||||
|
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
|
||||||
|
}
|
||||||
|
if (provider.testable) {
|
||||||
|
TestRow(provider = provider, testResult = testResult, onTest = onTest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
|
||||||
|
var key by remember(provider.id) { mutableStateOf("") }
|
||||||
|
OutlinedTextField(
|
||||||
|
value = key,
|
||||||
|
onValueChange = { key = it },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
label = { Text("API key") },
|
||||||
|
placeholder = {
|
||||||
|
Text(
|
||||||
|
if (provider.apiKeySet) {
|
||||||
|
"••• saved — leave blank to keep"
|
||||||
|
} else {
|
||||||
|
"Paste your API key to enable this source"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onSaveKey(provider.id, key)
|
||||||
|
key = ""
|
||||||
|
},
|
||||||
|
enabled = key.isNotBlank(),
|
||||||
|
) { Text("Save key") }
|
||||||
|
if (provider.apiKeySet) {
|
||||||
|
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TestRow(
|
||||||
|
provider: AdminTagSourceRef,
|
||||||
|
testResult: TagSourceTestResult?,
|
||||||
|
onTest: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
|
||||||
|
testResult?.let { result ->
|
||||||
|
if (result.ok) {
|
||||||
|
Text(
|
||||||
|
text = "OK (${result.durationMs}ms)",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "Failed — ${result.error}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
package com.fabledsword.minstrel.admin.ui
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
||||||
|
import com.fabledsword.minstrel.api.ErrorCopy
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.recoveries
|
||||||
|
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||||
|
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
sealed interface AdminTagSourcesUiState {
|
||||||
|
data object Loading : AdminTagSourcesUiState
|
||||||
|
data object Empty : AdminTagSourcesUiState
|
||||||
|
data class Success(
|
||||||
|
val providers: List<AdminTagSourceRef>,
|
||||||
|
val testResults: Map<String, TagSourceTestResult>,
|
||||||
|
) : AdminTagSourcesUiState
|
||||||
|
data class Error(val message: String) : AdminTagSourcesUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class AdminTagSourcesViewModel @Inject constructor(
|
||||||
|
private val repository: AdminTagSourcesRepository,
|
||||||
|
networkStatus: NetworkStatusController,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val internal = MutableStateFlow<AdminTagSourcesUiState>(AdminTagSourcesUiState.Loading)
|
||||||
|
val uiState: StateFlow<AdminTagSourcesUiState> = internal.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
refresh()
|
||||||
|
// Screen-level auto-recovery: reload a failed list when server
|
||||||
|
// health returns instead of waiting for a manual pull (issue #1245).
|
||||||
|
viewModelScope.launch {
|
||||||
|
networkStatus.recoveries().collect {
|
||||||
|
if (internal.value is AdminTagSourcesUiState.Error) refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh(): Job = viewModelScope.launch {
|
||||||
|
internal.value = AdminTagSourcesUiState.Loading
|
||||||
|
try {
|
||||||
|
val providers = repository.list()
|
||||||
|
internal.value = if (providers.isEmpty()) {
|
||||||
|
AdminTagSourcesUiState.Empty
|
||||||
|
} else {
|
||||||
|
AdminTagSourcesUiState.Success(providers, emptyMap())
|
||||||
|
}
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
internal.value = AdminTagSourcesUiState.Error(ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(id: String, enabled: Boolean) {
|
||||||
|
val before = internal.value as? AdminTagSourcesUiState.Success ?: return
|
||||||
|
// Optimistic toggle; reconcile via refresh() if the server rejects.
|
||||||
|
internal.value = before.copy(
|
||||||
|
providers = before.providers.map {
|
||||||
|
if (it.id == id) it.copy(enabled = enabled) else it
|
||||||
|
},
|
||||||
|
)
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.setEnabled(id, enabled)
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveApiKey(id: String, apiKey: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
replaceProvider(repository.setApiKey(id, apiKey))
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
|
||||||
|
) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun test(id: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = try {
|
||||||
|
repository.test(id)
|
||||||
|
} catch (
|
||||||
|
@Suppress("TooGenericExceptionCaught") e: Throwable,
|
||||||
|
) {
|
||||||
|
TagSourceTestResult(ok = false, error = ErrorCopy.fromThrowable(e))
|
||||||
|
}
|
||||||
|
val current = internal.value as? AdminTagSourcesUiState.Success ?: return@launch
|
||||||
|
internal.value = current.copy(testResults = current.testResults + (id to result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun replaceProvider(updated: AdminTagSourceRef) {
|
||||||
|
val current = internal.value as? AdminTagSourcesUiState.Success ?: return
|
||||||
|
internal.value = current.copy(
|
||||||
|
providers = current.providers.map { if (it.id == updated.id) updated else it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
@@ -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,
|
||||||
|
)
|
||||||
+3
@@ -239,6 +239,9 @@ data class PlayOfflinePayload(
|
|||||||
val atIso: String,
|
val atIso: String,
|
||||||
val durationPlayedMs: Long,
|
val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
@@ -254,6 +254,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
at = decoded.atIso,
|
at = decoded.atIso,
|
||||||
durationPlayedMs = decoded.durationPlayedMs,
|
durationPlayedMs = decoded.durationPlayedMs,
|
||||||
source = decoded.source,
|
source = decoded.source,
|
||||||
|
deviceClass = decoded.deviceClass,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Outcome.SENT
|
return Outcome.SENT
|
||||||
|
|||||||
@@ -2,11 +2,19 @@
|
|||||||
|
|
||||||
package com.fabledsword.minstrel.home.ui
|
package com.fabledsword.minstrel.home.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
@@ -27,6 +35,7 @@ import androidx.compose.foundation.lazy.grid.items as gridItems
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
@@ -88,6 +97,7 @@ import com.fabledsword.minstrel.shared.widgets.SkeletonSectionHeader
|
|||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
@@ -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_ROWS = 2
|
||||||
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
private const val RECENTLY_ADDED_GRID_HEIGHT_DP = 440
|
||||||
|
|
||||||
|
// "Updating your mixes…" veil (automatic refresh). Held through the pull
|
||||||
|
// plus VEIL_SETTLE_MS so per-tile hydration lands behind it before it wipes
|
||||||
|
// off; near-opaque (VEIL_ALPHA) so the section churn never bleeds through.
|
||||||
|
private const val VEIL_SETTLE_MS = 500L
|
||||||
|
private const val VEIL_WIPE_MS = 280
|
||||||
|
private const val VEIL_ALPHA = 0.96f
|
||||||
|
private const val VEIL_SPINNER_DP = 22
|
||||||
|
private const val VEIL_SPINNER_STROKE_DP = 2
|
||||||
|
private const val VEIL_LABEL_GAP_DP = 12
|
||||||
|
|
||||||
// ─── State ───────────────────────────────────────────────────────────
|
// ─── State ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class HomeSections(
|
data class HomeSections(
|
||||||
@@ -177,22 +197,36 @@ class HomeViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private val refreshError = MutableStateFlow<String?>(null)
|
private val refreshError = MutableStateFlow<String?>(null)
|
||||||
|
|
||||||
|
private val updatingInternal = MutableStateFlow(false)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while an automatic background refresh (the 03:00 daily rebuild
|
||||||
|
* or a reconnect re-pull) is repopulating Home. Drives the "Updating
|
||||||
|
* your mixes…" veil so the section churn — delete-then-insert in
|
||||||
|
* [HomeRepository.refreshIndex] plus per-tile hydration — happens
|
||||||
|
* hidden behind the veil instead of on screen. Manual pull-to-refresh
|
||||||
|
* and cold start are NOT veiled (they own the pull spinner / skeleton).
|
||||||
|
*/
|
||||||
|
val isUpdating: StateFlow<Boolean> = updatingInternal.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refresh()
|
refresh()
|
||||||
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
// Screen-level auto-recovery (issue #1245): a Home that failed to
|
||||||
// load while the server was unreachable re-pulls itself the moment
|
// load while the server was unreachable re-pulls itself the moment
|
||||||
// health returns — same idiom as SyncController, one layer up.
|
// health returns — same idiom as SyncController, one layer up.
|
||||||
|
// Veiled: content is already on screen and would otherwise churn.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
networkStatus.recoveries().collect { refresh() }
|
networkStatus.recoveries().collect { refreshBehindVeil() }
|
||||||
}
|
}
|
||||||
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
// #968: the daily 03:00 rebuild (and manual refresh) emit
|
||||||
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
// playlist.system_rebuilt; re-pull Home so the system-playlist tiles
|
||||||
// and You-might-like rows reflect the new snapshot without a manual
|
// and You-might-like rows reflect the new snapshot without a manual
|
||||||
// reload. Mirrors the web SSE consumer.
|
// reload. Mirrors the web SSE consumer. Veiled so the multi-section
|
||||||
|
// rebuild churn hides behind "Updating your mixes…".
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
eventsStream.events
|
eventsStream.events
|
||||||
.filter { it.kind == "playlist.system_rebuilt" }
|
.filter { it.kind == "playlist.system_rebuilt" }
|
||||||
.collect { refresh() }
|
.collect { refreshBehindVeil() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +357,26 @@ class HomeViewModel @Inject constructor(
|
|||||||
status.join()
|
status.join()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatic background refresh with the "Updating your mixes…" veil
|
||||||
|
* raised (see [isUpdating]). Used by the daily-rebuild + reconnect
|
||||||
|
* paths where Home is already on screen. Holds the veil through the
|
||||||
|
* pull plus a short settle so per-tile hydration lands behind it, then
|
||||||
|
* lets it wipe off. Overlapping automatic refreshes are rare enough
|
||||||
|
* (once-daily rebuild, reconnect) that a plain flag beats a counter.
|
||||||
|
*/
|
||||||
|
private fun refreshBehindVeil() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
updatingInternal.value = true
|
||||||
|
try {
|
||||||
|
refresh().join()
|
||||||
|
delay(VEIL_SETTLE_MS)
|
||||||
|
} finally {
|
||||||
|
updatingInternal.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val uiState: StateFlow<UiState<HomeSections>> =
|
val uiState: StateFlow<UiState<HomeSections>> =
|
||||||
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
combineHomeFlows().asCacheFirstStateFlow(viewModelScope)
|
||||||
|
|
||||||
@@ -398,47 +452,108 @@ fun HomeScreen(
|
|||||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
val systemStatus by viewModel.systemStatus.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
|
val updating by viewModel.isUpdating.collectAsStateWithLifecycle()
|
||||||
PullToRefreshScaffold(
|
PullToRefreshScaffold(
|
||||||
onRefresh = { viewModel.refresh().join() },
|
onRefresh = { viewModel.refresh().join() },
|
||||||
modifier = Modifier.fillMaxSize().padding(inner),
|
modifier = Modifier.fillMaxSize().padding(inner),
|
||||||
) {
|
) {
|
||||||
// Key Crossfade on the state CLASS, not the instance. Each
|
Box(Modifier.fillMaxSize()) {
|
||||||
// section emission produces a new UiState.Success(data); if
|
HomeStateCrossfade(state, systemStatus, offline, navController, viewModel)
|
||||||
// we keyed on `state` directly, every per-section
|
// Automatic-refresh veil: the daily rebuild / reconnect
|
||||||
// hydration tick would re-run the 300ms crossfade, and
|
// churn hides behind an "Updating your mixes…" wipe. Manual
|
||||||
// first-sign-in (six sections cascading in) reads as
|
// pull owns the PullToRefreshBox spinner instead.
|
||||||
// continuous flicker. Keying on the class restricts the
|
UpdatingVeil(visible = updating)
|
||||||
// animation to Loading↔Success↔Empty↔Error transitions and
|
}
|
||||||
// lets normal Success→Success recompositions update the
|
}
|
||||||
// LazyColumn without a fade.
|
}
|
||||||
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
}
|
||||||
when (val s = state) {
|
|
||||||
UiState.Loading -> HomeSkeletonContent()
|
/**
|
||||||
UiState.Empty -> EmptyState(
|
* The Loading/Empty/Error/Success switch for Home, crossfaded on state.
|
||||||
title = "Welcome to Minstrel",
|
*
|
||||||
body = "Nothing to show yet — scan a folder in your server " +
|
* Key the Crossfade on the state CLASS, not the instance. Each section
|
||||||
"settings, then come back here for system playlists " +
|
* emission produces a new UiState.Success(data); if we keyed on `state`
|
||||||
"and recommendations.",
|
* directly, every per-section hydration tick would re-run the 300ms
|
||||||
)
|
* crossfade, and first-sign-in (six sections cascading in) reads as
|
||||||
is UiState.Error -> ErrorRetry(
|
* continuous flicker. Keying on the class restricts the animation to
|
||||||
title = "Couldn't load home",
|
* Loading↔Success↔Empty↔Error transitions and lets normal Success→Success
|
||||||
message = s.message,
|
* recompositions update the LazyColumn without a fade.
|
||||||
onRetry = { viewModel.refresh() },
|
*/
|
||||||
)
|
@Composable
|
||||||
is UiState.Success -> HomeSuccessContent(
|
private fun HomeStateCrossfade(
|
||||||
sections = s.data,
|
state: UiState<HomeSections>,
|
||||||
systemStatus = systemStatus,
|
systemStatus: SystemPlaylistsStatus,
|
||||||
offline = offline,
|
offline: Boolean,
|
||||||
onAlbumClick = { id -> navController.navigate(AlbumDetail(id)) },
|
navController: NavHostController,
|
||||||
onArtistClick = { id -> navController.navigate(ArtistDetail(id)) },
|
viewModel: HomeViewModel,
|
||||||
onPlaylistClick = { id -> navController.navigate(PlaylistDetail(id)) },
|
) {
|
||||||
onMostPlayedTap = viewModel::playMostPlayed,
|
Crossfade(targetState = state::class, label = "home-state") { _ ->
|
||||||
onPlayPool = viewModel::playPool,
|
when (val s = state) {
|
||||||
onPlayAlbum = viewModel::playAlbum,
|
UiState.Loading -> HomeSkeletonContent()
|
||||||
onPlayArtist = viewModel::playArtistShuffled,
|
UiState.Empty -> EmptyState(
|
||||||
onPlayPlaylist = viewModel::playPlaylist,
|
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 {
|
item {
|
||||||
// Always rendered: real system/user playlists, with
|
// Always rendered: real system/user playlists, with
|
||||||
// placeholder cards filling the For You / Discover /
|
// placeholder cards filling the For You / Discover slots
|
||||||
// 3× Songs-like slots that haven't generated yet. When
|
// that haven't generated yet. When offline, the cache-backed
|
||||||
// offline, the cache-backed pool cards lead the row.
|
// pool cards lead the row. Songs-like now lives in its own
|
||||||
|
// dedicated row below (#1491), no longer inside this carousel.
|
||||||
PlaylistsRow(
|
PlaylistsRow(
|
||||||
|
title = "Playlists",
|
||||||
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
rowItems = buildPlaylistsRow(sections.playlists, systemStatus, offline),
|
||||||
offline = offline,
|
offline = offline,
|
||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
@@ -519,6 +636,18 @@ private fun HomeSuccessContent(
|
|||||||
onPlayPlaylist = onPlayPlaylist,
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
// Songs-like is the best-performing surface (#1491) — promoted out
|
||||||
|
// of the Playlists carousel into its own row so it shows a wider
|
||||||
|
// spread of "Songs like {artist}" mixes. Hidden when there's
|
||||||
|
// nothing to show (offline with none cached).
|
||||||
|
songsLikeSection(
|
||||||
|
playlists = sections.playlists,
|
||||||
|
status = systemStatus,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
youMightLikeSection(
|
youMightLikeSection(
|
||||||
albums = sections.youMightLikeAlbums,
|
albums = sections.youMightLikeAlbums,
|
||||||
artists = sections.youMightLikeArtists,
|
artists = sections.youMightLikeArtists,
|
||||||
@@ -542,6 +671,35 @@ private fun HomeSuccessContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dedicated "Songs like…" row (#1491). Reuses [PlaylistsRow]'s card
|
||||||
|
* rendering with a distinct title; hidden entirely when there's nothing
|
||||||
|
* to show (offline with no cached mixes). Online with none generated yet
|
||||||
|
* still shows a few placeholders so the building / seed-needed state is
|
||||||
|
* visible, matching the pre-promotion carousel behavior.
|
||||||
|
*/
|
||||||
|
private fun LazyListScope.songsLikeSection(
|
||||||
|
playlists: List<PlaylistRef>,
|
||||||
|
status: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
onPlaylistClick: (String) -> Unit,
|
||||||
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
|
) {
|
||||||
|
val rowItems = buildSongsLikeRow(playlists, status, offline)
|
||||||
|
if (rowItems.isEmpty()) return
|
||||||
|
item {
|
||||||
|
PlaylistsRow(
|
||||||
|
title = "Songs like…",
|
||||||
|
rowItems = rowItems,
|
||||||
|
offline = offline,
|
||||||
|
onPlaylistClick = onPlaylistClick,
|
||||||
|
onPlayPool = onPlayPool,
|
||||||
|
onPlayPlaylist = onPlayPlaylist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun LazyListScope.recentlyAddedSection(
|
private fun LazyListScope.recentlyAddedSection(
|
||||||
albums: List<HomeTile<AlbumRef>>,
|
albums: List<HomeTile<AlbumRef>>,
|
||||||
onAlbumClick: (String) -> Unit,
|
onAlbumClick: (String) -> Unit,
|
||||||
@@ -811,13 +969,14 @@ private fun AlbumsRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlaylistsRow(
|
private fun PlaylistsRow(
|
||||||
|
title: String,
|
||||||
rowItems: List<PlaylistRowItem>,
|
rowItems: List<PlaylistRowItem>,
|
||||||
offline: Boolean,
|
offline: Boolean,
|
||||||
onPlaylistClick: (String) -> Unit,
|
onPlaylistClick: (String) -> Unit,
|
||||||
onPlayPool: (OfflinePoolKind) -> Unit,
|
onPlayPool: (OfflinePoolKind) -> Unit,
|
||||||
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
onPlayPlaylist: suspend (PlaylistRef) -> Unit,
|
||||||
) {
|
) {
|
||||||
HorizontalScrollRow(title = "Playlists") {
|
HorizontalScrollRow(title = title) {
|
||||||
itemsIndexed(items = rowItems) { _, item ->
|
itemsIndexed(items = rowItems) { _, item ->
|
||||||
when (item) {
|
when (item) {
|
||||||
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
is PlaylistRowItem.OfflinePool -> OfflinePoolCard(
|
||||||
@@ -868,11 +1027,12 @@ enum class OfflinePoolKind(val label: String) {
|
|||||||
/**
|
/**
|
||||||
* Builds the Home Playlists row.
|
* Builds the Home Playlists row.
|
||||||
*
|
*
|
||||||
* Online: For You + Discover + 3× Songs-like fixed slots (real card when
|
* Online: For You + Discover fixed slots (real card when generated,
|
||||||
* generated, placeholder otherwise), then the secondary system kinds (deep cuts
|
* placeholder otherwise), then the secondary system kinds (deep cuts /
|
||||||
* / rediscover / new for you / on this day / first listens) when they exist —
|
* rediscover / new for you / on this day / first listens) when they exist —
|
||||||
* no placeholders for these since they're conditional on library shape — then
|
* no placeholders for these since they're conditional on library shape — then
|
||||||
* user-owned playlists.
|
* user-owned playlists. Songs-like has its own dedicated row (#1491) via
|
||||||
|
* [buildSongsLikeRow] and no longer appears in this carousel.
|
||||||
*
|
*
|
||||||
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
* Offline: the two cache-backed pools (Recently played, Liked) lead, then the
|
||||||
* same real playlists in curated order but stably partitioned fully-cached
|
* same real playlists in curated order but stably partitioned fully-cached
|
||||||
@@ -899,7 +1059,7 @@ internal fun buildPlaylistsRow(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The online layout: fixed system slots (with placeholders), secondary, user. */
|
/** The online layout: For You + Discover slots (with placeholders), secondary, user. */
|
||||||
private fun buildOnlineRow(
|
private fun buildOnlineRow(
|
||||||
owned: List<PlaylistRef>,
|
owned: List<PlaylistRef>,
|
||||||
status: SystemPlaylistsStatus,
|
status: SystemPlaylistsStatus,
|
||||||
@@ -911,12 +1071,7 @@ private fun buildOnlineRow(
|
|||||||
out += owned.firstOrNull { it.systemVariant == "discover" }
|
out += owned.firstOrNull { it.systemVariant == "discover" }
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
?.let { PlaylistRowItem.Real(it) }
|
||||||
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
?: PlaylistRowItem.Placeholder("Discover", variantFor("discover", status))
|
||||||
val songsLike = owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
// Songs-like is no longer here — it has its own dedicated row (#1491).
|
||||||
for (i in 0 until SONGS_LIKE_SLOTS) {
|
|
||||||
out += songsLike.getOrNull(i)
|
|
||||||
?.let { PlaylistRowItem.Real(it) }
|
|
||||||
?: PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
|
||||||
}
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += PlaylistRowItem.Real(it) }
|
||||||
}
|
}
|
||||||
@@ -924,16 +1079,44 @@ private fun buildOnlineRow(
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the dedicated Songs-like row (#1491): all "Songs like {artist}"
|
||||||
|
* mixes the server generated, no longer capped to the 3 carousel slots.
|
||||||
|
*
|
||||||
|
* Online: every generated mix as a real card; when none exist yet, a few
|
||||||
|
* placeholders so the building / seed-needed state stays visible.
|
||||||
|
* Offline: the cached mixes only (fully-cached first, greyed after), and
|
||||||
|
* an empty list — hiding the whole section — when nothing is cached.
|
||||||
|
*/
|
||||||
|
internal fun buildSongsLikeRow(
|
||||||
|
owned: List<PlaylistRef>,
|
||||||
|
status: SystemPlaylistsStatus,
|
||||||
|
offline: Boolean,
|
||||||
|
): List<PlaylistRowItem> {
|
||||||
|
val mixes = owned.filter { it.systemVariant == "songs_like_artist" }
|
||||||
|
return when {
|
||||||
|
offline -> {
|
||||||
|
val (available, greyed) = mixes.partition { !it.unavailableOffline }
|
||||||
|
(available + greyed).map { PlaylistRowItem.Real(it) }
|
||||||
|
}
|
||||||
|
mixes.isNotEmpty() -> mixes.map { PlaylistRowItem.Real(it) }
|
||||||
|
else -> List(SONGS_LIKE_PLACEHOLDER_SLOTS) {
|
||||||
|
PlaylistRowItem.Placeholder("Songs like…", variantFor("songs-like", status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Curated real-playlist order (system primaries, then secondary, then user).
|
* Curated real-playlist order (system primaries, then secondary, then user).
|
||||||
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
* Must mirror [buildOnlineRow]'s slot order — the offline row reuses this and
|
||||||
* only differs by dropping placeholders + partitioning available-first.
|
* only differs by dropping placeholders + partitioning available-first.
|
||||||
|
* Songs-like is excluded here too — it renders in its own row via
|
||||||
|
* [buildSongsLikeRow] in both online and offline modes (#1491).
|
||||||
*/
|
*/
|
||||||
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
private fun orderedRealPlaylists(owned: List<PlaylistRef>): List<PlaylistRef> {
|
||||||
val out = mutableListOf<PlaylistRef>()
|
val out = mutableListOf<PlaylistRef>()
|
||||||
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == "for_you" }?.let { out += it }
|
||||||
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == "discover" }?.let { out += it }
|
||||||
out += owned.filter { it.systemVariant == "songs_like_artist" }.take(SONGS_LIKE_SLOTS)
|
|
||||||
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
for (variant in SECONDARY_SYSTEM_VARIANTS) {
|
||||||
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
owned.firstOrNull { it.systemVariant == variant }?.let { out += it }
|
||||||
}
|
}
|
||||||
@@ -948,7 +1131,10 @@ private fun variantFor(slot: String, s: SystemPlaylistsStatus): String = when {
|
|||||||
else -> "pending"
|
else -> "pending"
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SONGS_LIKE_SLOTS = 3
|
// How many "Songs like…" placeholder cards the dedicated row shows while
|
||||||
|
// the mixes haven't generated yet (building / seed-needed). Real mixes,
|
||||||
|
// once generated, are shown in full and no longer capped by this (#1491).
|
||||||
|
private const val SONGS_LIKE_PLACEHOLDER_SLOTS = 3
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 5 system playlist kinds the server generates that aren't pinned
|
* The 5 system playlist kinds the server generates that aren't pinned
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.fabledsword.minstrel.models
|
||||||
|
|
||||||
|
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
|
||||||
|
data class AdminTagSourceRef(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val requiresApiKey: Boolean,
|
||||||
|
val supports: List<String>,
|
||||||
|
val enabled: Boolean,
|
||||||
|
val apiKeySet: Boolean,
|
||||||
|
val testable: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Result of a provider "test connection" call. */
|
||||||
|
data class TagSourceTestResult(
|
||||||
|
val ok: Boolean,
|
||||||
|
val durationMs: Long = 0,
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.fabledsword.minstrel.models.wire
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
|
||||||
|
* Every field defaults defensively so a missing JSON key never crashes
|
||||||
|
* deserialization. `versionBumped` is present only on the PATCH response.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AdminTagSourceWire(
|
||||||
|
val id: String = "",
|
||||||
|
@SerialName("display_name") val displayName: String = "",
|
||||||
|
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
|
||||||
|
val supports: List<String> = emptyList(),
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
@SerialName("api_key_set") val apiKeySet: Boolean = false,
|
||||||
|
@SerialName("display_order") val displayOrder: Int = 0,
|
||||||
|
val testable: Boolean = false,
|
||||||
|
@SerialName("version_bumped") val versionBumped: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdminTagSourcesListWire(
|
||||||
|
val providers: List<AdminTagSourceWire> = emptyList(),
|
||||||
|
@SerialName("sources_version") val sourcesVersion: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TestTagSourceWire(
|
||||||
|
val ok: Boolean = false,
|
||||||
|
@SerialName("duration_ms") val durationMs: Long = 0,
|
||||||
|
val error: String = "",
|
||||||
|
)
|
||||||
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
|
|||||||
@SerialName("track_id") val trackId: String,
|
@SerialName("track_id") val trackId: String,
|
||||||
@SerialName("client_id") val clientId: String,
|
@SerialName("client_id") val clientId: String,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning (server normalizes).
|
||||||
|
@SerialName("device_class") val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
|
|||||||
val at: String,
|
val at: String,
|
||||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||||
val source: String? = null,
|
val source: String? = null,
|
||||||
|
// #1551: device class for context conditioning (server normalizes).
|
||||||
|
@SerialName("device_class") val deviceClass: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
|
|||||||
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
||||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
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.admin.ui.AdminUsersScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
||||||
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
||||||
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
composable<AdminTagSources> {
|
||||||
|
WithAnimatedScope {
|
||||||
|
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||||
|
AdminTagSourcesScreen(navController = navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
|||||||
@Serializable data object AdminRequests
|
@Serializable data object AdminRequests
|
||||||
@Serializable data object AdminQuarantine
|
@Serializable data object AdminQuarantine
|
||||||
@Serializable data object AdminUsers
|
@Serializable data object AdminUsers
|
||||||
|
@Serializable data object AdminTagSources
|
||||||
|
|
||||||
// ── Outside-shell full-screen routes ──────────────────────────────────
|
// ── Outside-shell full-screen routes ──────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
clientId = cid,
|
clientId = cid,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
|
deviceClass = DEVICE_CLASS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (curTrackId == trackId) {
|
if (curTrackId == trackId) {
|
||||||
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
atIso = startedAt.toString(),
|
atIso = startedAt.toString(),
|
||||||
durationPlayedMs = durationPlayedMs,
|
durationPlayedMs = durationPlayedMs,
|
||||||
source = source.takeIf { !it.isNullOrEmpty() },
|
source = source.takeIf { !it.isNullOrEmpty() },
|
||||||
|
deviceClass = DEVICE_CLASS,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
|
|||||||
|
|
||||||
// ── Lifecycle: durable-close on app background / detach ────────
|
// ── 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) {
|
override fun onStop(owner: LifecycleOwner) {
|
||||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||||
// alone: the foreground media service keeps the process — and this
|
// alone: the foreground media service keeps the process — and this
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
|
|||||||
* ServerImage + fallback structure was identical at all three sites.
|
* ServerImage + fallback structure was identical at all three sites.
|
||||||
*
|
*
|
||||||
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
* [overlay] is a `BoxScope` slot for things drawn on top of the cover
|
||||||
* (e.g. the `VariantPill` system-playlist label) — callers can use
|
* (e.g. the `VariantPill` system-playlist label, the play button) —
|
||||||
* `Modifier.align(...)` inside it.
|
* callers can use `Modifier.align(...)` inside it. The overlay sits on
|
||||||
|
* an outer, UN-clipped box so corner-anchored widgets are not cut off by
|
||||||
|
* [shape]: a `BottomEnd` play button on a `CircleShape` avatar falls in
|
||||||
|
* the square's corner, outside the circle, and would otherwise be clipped
|
||||||
|
* away. Only the artwork + background are clipped to [shape].
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun CoverTile(
|
fun CoverTile(
|
||||||
@@ -38,18 +42,23 @@ fun CoverTile(
|
|||||||
overlay: @Composable BoxScope.() -> Unit = {},
|
overlay: @Composable BoxScope.() -> Unit = {},
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier.size(size),
|
||||||
.size(size)
|
|
||||||
.clip(shape)
|
|
||||||
.background(background),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
ServerImage(
|
Box(
|
||||||
url = url,
|
modifier = Modifier
|
||||||
contentDescription = contentDescription,
|
.fillMaxSize()
|
||||||
modifier = Modifier.fillMaxSize(),
|
.clip(shape)
|
||||||
|
.background(background),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
fallback()
|
ServerImage(
|
||||||
|
url = url,
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
fallback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
overlay()
|
overlay()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,51 @@ class BuildPlaylistsRowTest {
|
|||||||
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
assertTrue(row.none { it is PlaylistRowItem.OfflinePool })
|
||||||
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
assertTrue(row.any { it is PlaylistRowItem.Placeholder })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun songsLike(id: String, cached: Boolean) = PlaylistRef(
|
||||||
|
id = id,
|
||||||
|
userId = "u",
|
||||||
|
name = "Songs like $id",
|
||||||
|
systemVariant = "songs_like_artist",
|
||||||
|
trackCount = 25,
|
||||||
|
fullyCached = cached,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `songs-like no longer appears in the main playlists carousel`() {
|
||||||
|
val owned = listOf(songsLike("a", cached = true), user("u1", cached = true))
|
||||||
|
val row = buildPlaylistsRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
assertTrue("a" !in reals, "songs-like mix leaked into the Playlists row: $reals")
|
||||||
|
assertTrue("u1" in reals)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `online songs-like row shows every generated mix uncapped`() {
|
||||||
|
// Six generated mixes — the old carousel capped at 3; the dedicated row shows all.
|
||||||
|
val owned = (1..6).map { songsLike("a$it", cached = true) }
|
||||||
|
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = false)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
assertEquals((1..6).map { "a$it" }, reals)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `online songs-like row shows placeholders when none generated`() {
|
||||||
|
val row = buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = false)
|
||||||
|
assertTrue(row.isNotEmpty())
|
||||||
|
assertTrue(row.all { it is PlaylistRowItem.Placeholder })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `offline songs-like row shows cached mixes available-first, none hides it`() {
|
||||||
|
val owned = listOf(songsLike("partial", cached = false), songsLike("full", cached = true))
|
||||||
|
val row = buildSongsLikeRow(owned, SystemPlaylistsStatus(), offline = true)
|
||||||
|
val reals = row.filterIsInstance<PlaylistRowItem.Real>().map { it.playlist.id }
|
||||||
|
// Fully-cached songs-like mix (not refreshable) is available; the
|
||||||
|
// un-cached one greys and sorts after. No placeholders offline.
|
||||||
|
assertEquals(listOf("full", "partial"), reals)
|
||||||
|
assertTrue(row.none { it is PlaylistRowItem.Placeholder })
|
||||||
|
|
||||||
|
assertTrue(buildSongsLikeRow(emptyList(), SystemPlaylistsStatus(), offline = true).isEmpty())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
"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/coverart"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
"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/similarity"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
syncpkg "git.fabledsword.com/bvandeusen/minstrel/internal/sync"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -205,6 +207,31 @@ func run() error {
|
|||||||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||||||
go similarityWorker.Run(ctx)
|
go similarityWorker.Run(ctx)
|
||||||
|
|
||||||
|
// Start the 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
|
// Start the GC worker. Runs every 1h and sweeps lifecycle tables
|
||||||
// that have no writer-side close path or retention policy:
|
// that have no writer-side close path or retention policy:
|
||||||
// orphan play_events, stale play_sessions, expired
|
// orphan play_events, stale play_sessions, expired
|
||||||
@@ -306,6 +333,7 @@ func run() error {
|
|||||||
srv.Bus = bus
|
srv.Bus = bus
|
||||||
srv.PlaylistScheduler = playlistScheduler
|
srv.PlaylistScheduler = playlistScheduler
|
||||||
srv.RecSettings = recSettings
|
srv.RecSettings = recSettings
|
||||||
|
srv.TagSettings = tagSettings
|
||||||
srv.StreamSecret = cfg.StreamSecret
|
srv.StreamSecret = cfg.StreamSecret
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: cfg.Server.Address,
|
Addr: cfg.Server.Address,
|
||||||
|
|||||||
@@ -20,26 +20,28 @@ import (
|
|||||||
// weightsResp is one weight profile on the wire, keyed by the same
|
// weightsResp is one weight profile on the wire, keyed by the same
|
||||||
// snake_case field names the PATCH body accepts.
|
// snake_case field names the PATCH body accepts.
|
||||||
type weightsResp struct {
|
type weightsResp struct {
|
||||||
BaseWeight float64 `json:"base_weight"`
|
BaseWeight float64 `json:"base_weight"`
|
||||||
LikeBoost float64 `json:"like_boost"`
|
LikeBoost float64 `json:"like_boost"`
|
||||||
RecencyWeight float64 `json:"recency_weight"`
|
RecencyWeight float64 `json:"recency_weight"`
|
||||||
SkipPenalty float64 `json:"skip_penalty"`
|
SkipPenalty float64 `json:"skip_penalty"`
|
||||||
JitterMagnitude float64 `json:"jitter_magnitude"`
|
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||||
ContextWeight float64 `json:"context_weight"`
|
ContextWeight float64 `json:"context_weight"`
|
||||||
SimilarityWeight float64 `json:"similarity_weight"`
|
SimilarityWeight float64 `json:"similarity_weight"`
|
||||||
TasteWeight float64 `json:"taste_weight"`
|
TasteWeight float64 `json:"taste_weight"`
|
||||||
|
ContextTimeWeight float64 `json:"context_time_weight"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
||||||
return weightsResp{
|
return weightsResp{
|
||||||
BaseWeight: w.BaseWeight,
|
BaseWeight: w.BaseWeight,
|
||||||
LikeBoost: w.LikeBoost,
|
LikeBoost: w.LikeBoost,
|
||||||
RecencyWeight: w.RecencyWeight,
|
RecencyWeight: w.RecencyWeight,
|
||||||
SkipPenalty: w.SkipPenalty,
|
SkipPenalty: w.SkipPenalty,
|
||||||
JitterMagnitude: w.JitterMagnitude,
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
ContextWeight: w.ContextWeight,
|
ContextWeight: w.ContextWeight,
|
||||||
SimilarityWeight: w.SimilarityWeight,
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
TasteWeight: w.TasteWeight,
|
TasteWeight: w.TasteWeight,
|
||||||
|
ContextTimeWeight: w.ContextTimeWeight,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,9 @@ type tasteTuningResp struct {
|
|||||||
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||||
EngagementNeutral float64 `json:"engagement_neutral"`
|
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||||
EngagementFull float64 `json:"engagement_full"`
|
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 {
|
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||||
@@ -56,6 +61,9 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
|||||||
EngagementHardSkip: t.EngagementHardSkip,
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
EngagementNeutral: t.EngagementNeutral,
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
EngagementFull: t.EngagementFull,
|
EngagementFull: t.EngagementFull,
|
||||||
|
EnrichedTagScale: t.EnrichedTagScale,
|
||||||
|
EraScale: t.EraScale,
|
||||||
|
MoodScale: t.MoodScale,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tagSourceResp is the wire shape for one tag-enrichment provider in the
|
||||||
|
// GET /api/admin/tag-sources list. Mirrors the cover-sources admin surface
|
||||||
|
// (admin_cover_sources.go) — a separate, independent settings card so a new
|
||||||
|
// tag source is added without touching art settings (#1490).
|
||||||
|
type tagSourceResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DisplayName string `json:"display_name"`
|
||||||
|
RequiresAPIKey bool `json:"requires_api_key"`
|
||||||
|
Supports []string `json:"supports"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
APIKeySet bool `json:"api_key_set"`
|
||||||
|
DisplayOrder int32 `json:"display_order"`
|
||||||
|
Testable bool `json:"testable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func tagSourceRespFrom(info tags.ProviderInfo) tagSourceResp {
|
||||||
|
supports := info.Supports
|
||||||
|
if supports == nil {
|
||||||
|
supports = []string{}
|
||||||
|
}
|
||||||
|
return tagSourceResp{
|
||||||
|
ID: info.ID,
|
||||||
|
DisplayName: info.DisplayName,
|
||||||
|
RequiresAPIKey: info.RequiresAPIKey,
|
||||||
|
Supports: supports,
|
||||||
|
Enabled: info.Enabled,
|
||||||
|
APIKeySet: info.APIKeySet,
|
||||||
|
DisplayOrder: info.DisplayOrder,
|
||||||
|
Testable: info.Testable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tagSourcesListResp struct {
|
||||||
|
Providers []tagSourceResp `json:"providers"`
|
||||||
|
SourcesVersion int32 `json:"sources_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListTagSources implements GET /api/admin/tag-sources.
|
||||||
|
func (h *handlers) handleListTagSources(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
infos := h.tagSettings.ListProviderInfo()
|
||||||
|
out := tagSourcesListResp{
|
||||||
|
Providers: make([]tagSourceResp, 0, len(infos)),
|
||||||
|
SourcesVersion: h.tagSettings.CurrentVersion(),
|
||||||
|
}
|
||||||
|
for _, info := range infos {
|
||||||
|
out.Providers = append(out.Providers, tagSourceRespFrom(info))
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTagSourceReq is the PATCH body. nil pointers mean "leave
|
||||||
|
// unchanged"; non-nil sets (empty api_key clears).
|
||||||
|
type updateTagSourceReq struct {
|
||||||
|
Enabled *bool `json:"enabled,omitempty"`
|
||||||
|
APIKey *string `json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateTagSourceResp struct {
|
||||||
|
tagSourceResp
|
||||||
|
VersionBumped bool `json:"version_bumped"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleUpdateTagSource implements PATCH /api/admin/tag-sources/{provider_id}.
|
||||||
|
func (h *handlers) handleUpdateTagSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "provider_id")
|
||||||
|
|
||||||
|
var body updateTagSourceReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bumped, err := h.tagSettings.UpdateProvider(r.Context(), id, tags.ProviderUpdatePatch{
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
APIKey: body.APIKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, tags.ErrProviderNotFound) {
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.logger.Error("admin: update tag source", "id", id, "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("update failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var found tagSourceResp
|
||||||
|
for _, info := range h.tagSettings.ListProviderInfo() {
|
||||||
|
if info.ID == id {
|
||||||
|
found = tagSourceRespFrom(info)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, updateTagSourceResp{tagSourceResp: found, VersionBumped: bumped})
|
||||||
|
}
|
||||||
|
|
||||||
|
type testTagSourceResp struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
DurationMs int64 `json:"duration_ms,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTestTagSource implements POST /api/admin/tag-sources/{provider_id}/test.
|
||||||
|
func (h *handlers) handleTestTagSource(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "provider_id")
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := h.tagSettings.TestProvider(r.Context(), id)
|
||||||
|
duration := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
writeJSON(w, http.StatusOK, testTagSourceResp{OK: true, DurationMs: duration})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, tags.ErrProviderNotFound) {
|
||||||
|
writeErr(w, &apierror.Error{Status: http.StatusNotFound, Code: "not_found", Message: "no such provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Not testable, or the test failed — both surface as ok=false with a
|
||||||
|
// 200 (the operation completed; the result is data, not an error).
|
||||||
|
writeJSON(w, http.StatusOK, testTagSourceResp{OK: false, DurationMs: duration, Error: err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
type researchTagsResp struct {
|
||||||
|
SourcesVersion int32 `json:"sources_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResearchTags implements POST /api/admin/tag-sources/research: bump
|
||||||
|
// the sources version so every settled ('none') track becomes eligible for
|
||||||
|
// a re-enrichment pass, then the background worker re-processes them.
|
||||||
|
func (h *handlers) handleResearchTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
newVer, err := h.tagSettings.BumpVersion(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("admin: research tags", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("research failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, researchTagsResp{SourcesVersion: newVer})
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
|
)
|
||||||
|
|
||||||
|
// apiTestTagProvider is a minimal TrackTagProvider for api-package tests.
|
||||||
|
// Does not implement TestableProvider.
|
||||||
|
type apiTestTagProvider struct {
|
||||||
|
id string
|
||||||
|
display string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiTestTagProvider) ID() string { return p.id }
|
||||||
|
func (p *apiTestTagProvider) DisplayName() string { return p.display }
|
||||||
|
func (p *apiTestTagProvider) RequiresAPIKey() bool { return false }
|
||||||
|
func (p *apiTestTagProvider) DefaultEnabled() bool { return true }
|
||||||
|
func (p *apiTestTagProvider) Configure(_ tags.ProviderSettings) error { return nil }
|
||||||
|
func (p *apiTestTagProvider) FetchTrackTags(_ context.Context, _ tags.TrackRef) ([]tags.Tag, error) {
|
||||||
|
return []tags.Tag{{Name: "x", Weight: 1}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiTestTestableTagProvider also implements TestableProvider.
|
||||||
|
type apiTestTestableTagProvider struct {
|
||||||
|
apiTestTagProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *apiTestTestableTagProvider) TestConnection(_ context.Context) error { return nil }
|
||||||
|
|
||||||
|
func newAdminTagSourcesRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Route("/api/admin", func(admin chi.Router) {
|
||||||
|
admin.Use(auth.RequireAdmin())
|
||||||
|
admin.Get("/tag-sources", h.handleListTagSources)
|
||||||
|
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
||||||
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||||
|
})
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// testHandlersWithTagSettings installs a tags.SettingsService backed by the
|
||||||
|
// test DB. Register the needed fake providers BEFORE calling this, and pair
|
||||||
|
// with tags.ResetRegistryForTests in t.Cleanup.
|
||||||
|
func testHandlersWithTagSettings(t *testing.T) *handlers {
|
||||||
|
t.Helper()
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
s, err := tags.NewSettingsService(context.Background(), pool, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewSettingsService: %v", err)
|
||||||
|
}
|
||||||
|
h.tagSettings = s
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminListTagSources_ReturnsRegisteredProviders(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "test-tag-prov", display: "Test Tag Provider"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tslist", "pw", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/admin/tag-sources", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp tagSourcesListResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(resp.Providers) != 1 || resp.Providers[0].ID != "test-tag-prov" {
|
||||||
|
t.Fatalf("providers = %+v, want 1 (test-tag-prov)", resp.Providers)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, s := range resp.Providers[0].Supports {
|
||||||
|
if s == "track_tags" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("supports = %v, want to include track_tags", resp.Providers[0].Supports)
|
||||||
|
}
|
||||||
|
if resp.Providers[0].Testable {
|
||||||
|
t.Error("testable = true, want false (no TestConnection)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_FlippingEnabledBumpsVersion(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "flip-tag", display: "Flip Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tsflip", "pw", true)
|
||||||
|
|
||||||
|
disabled := false
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{Enabled: &disabled})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/flip-tag", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp updateTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.VersionBumped {
|
||||||
|
t.Error("version_bumped = false, want true after flipping enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_KeyOnlyDoesNotBump(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "key-tag", display: "Key Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tskey", "pw", true)
|
||||||
|
|
||||||
|
newKey := "newkey"
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{APIKey: &newKey})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/key-tag", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp updateTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.VersionBumped {
|
||||||
|
t.Error("version_bumped = true, want false for key-only change")
|
||||||
|
}
|
||||||
|
if !resp.APIKeySet {
|
||||||
|
t.Error("api_key_set = false, want true after setting key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminUpdateTagSource_UnknownProvider404(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "ts404", "pw", true)
|
||||||
|
|
||||||
|
body, _ := json.Marshal(updateTagSourceReq{})
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/admin/tag-sources/missing", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestTagSource_NonAdminReturns403(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTestableTagProvider{apiTestTagProvider{id: "testable-tag", display: "Testable Tag"}})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
nonAdmin := seedUser(t, h.pool, "tsnoadmin", "pw", false)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/testable-tag/test", nil)
|
||||||
|
req = withUser(req, nonAdmin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusForbidden {
|
||||||
|
t.Fatalf("status = %d, want 403; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminTestTagSource_NotTestableReturnsOkFalse(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
tags.ResetRegistryForTests()
|
||||||
|
t.Cleanup(tags.ResetRegistryForTests)
|
||||||
|
tags.Register(&apiTestTagProvider{id: "nontestable-tag", display: "Non-Testable Tag"})
|
||||||
|
|
||||||
|
h := testHandlersWithTagSettings(t)
|
||||||
|
admin := seedUser(t, h.pool, "tsntest", "pw", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/admin/tag-sources/nontestable-tag/test", nil)
|
||||||
|
req = withUser(req, admin)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newAdminTagSourcesRouter(h).ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var resp testTagSourceResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.OK {
|
||||||
|
t.Error("ok = true, want false for non-testable provider")
|
||||||
|
}
|
||||||
|
if resp.Error == "" {
|
||||||
|
t.Error("error string empty, want a message")
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
-1
@@ -23,13 +23,14 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playevents"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
// Mount attaches /api/* handlers to r. Public endpoints (login) are outside
|
||||||
// RequireUser; everything else is gated by the middleware. The events writer
|
// RequireUser; everything else is gated by the middleware. The events writer
|
||||||
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
// is shared with the Subsonic mount so /rest/scrobble feeds the same store.
|
||||||
func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, 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()))
|
rng := rand.New(rand.NewSource(rand.Int63()))
|
||||||
h := &handlers{
|
h := &handlers{
|
||||||
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
pool: pool, logger: logger, events: events, recCfg: recCfg,
|
||||||
@@ -42,6 +43,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
playlists: playlistsSvc,
|
playlists: playlistsSvc,
|
||||||
coverart: coverEnricher,
|
coverart: coverEnricher,
|
||||||
coverSettings: coverSettings,
|
coverSettings: coverSettings,
|
||||||
|
tagSettings: tagSettings,
|
||||||
scanner: scanner,
|
scanner: scanner,
|
||||||
scanCfg: scanCfg,
|
scanCfg: scanCfg,
|
||||||
dataDir: dataDir,
|
dataDir: dataDir,
|
||||||
@@ -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/{provider_id}/test", h.handleTestCoverSource)
|
||||||
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
admin.Post("/cover-sources/research", h.handleResearchMissingArt)
|
||||||
|
|
||||||
|
admin.Get("/tag-sources", h.handleListTagSources)
|
||||||
|
admin.Patch("/tag-sources/{provider_id}", h.handleUpdateTagSource)
|
||||||
|
admin.Post("/tag-sources/{provider_id}/test", h.handleTestTagSource)
|
||||||
|
admin.Post("/tag-sources/research", h.handleResearchTags)
|
||||||
|
|
||||||
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
admin.Get("/smtp-config", h.handleGetSMTPConfig)
|
||||||
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
admin.Put("/smtp-config", h.handleUpdateSMTPConfig)
|
||||||
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
admin.Post("/smtp-config/test", h.handleTestSMTPConfig)
|
||||||
@@ -242,6 +249,7 @@ type handlers struct {
|
|||||||
playlists *playlists.Service
|
playlists *playlists.Service
|
||||||
coverart *coverart.Enricher
|
coverart *coverart.Enricher
|
||||||
coverSettings *coverart.SettingsService
|
coverSettings *coverart.SettingsService
|
||||||
|
tagSettings *tags.SettingsService
|
||||||
scanner *library.Scanner
|
scanner *library.Scanner
|
||||||
scanCfg library.RunScanConfig
|
scanCfg library.RunScanConfig
|
||||||
dataDir string
|
dataDir string
|
||||||
|
|||||||
+33
-2
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -24,6 +25,33 @@ type eventRequest struct {
|
|||||||
// / "discover" feed the system-playlist rotation dedup (#415);
|
// / "discover" feed the system-playlist rotation dedup (#415);
|
||||||
// absent / "" for library, user-playlist, radio, Subsonic.
|
// absent / "" for library, user-playlist, radio, Subsonic.
|
||||||
Source *string `json:"source"`
|
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 {
|
type playStartedResponse struct {
|
||||||
@@ -114,7 +142,9 @@ func (h *handlers) handleEventPlayStarted(
|
|||||||
if req.Source != nil {
|
if req.Source != nil {
|
||||||
source = *req.Source
|
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 {
|
if err != nil {
|
||||||
h.logger.Error("api: events: play_started", "err", err)
|
h.logger.Error("api: events: play_started", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||||
@@ -159,7 +189,8 @@ func (h *handlers) handleEventPlayOffline(
|
|||||||
source = *req.Source
|
source = *req.Source
|
||||||
}
|
}
|
||||||
if err := h.events.RecordOfflinePlay(
|
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 {
|
); err != nil {
|
||||||
h.logger.Error("api: events: play_offline", "err", err)
|
h.logger.Error("api: events: play_offline", "err", err)
|
||||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||||
30*time.Minute, 0.5, 30000)
|
30*time.Minute, 0.5, 30000)
|
||||||
Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.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{
|
paths := []string{
|
||||||
"/api/artists",
|
"/api/artists",
|
||||||
|
|||||||
@@ -171,18 +171,19 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pickKindLabels is the display vocabulary for play_events.pick_kind
|
// 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
|
// stamps provenance gets its breakdown from this one map — adding a
|
||||||
// stamping mix needs no metrics change.
|
// stamping mix needs no metrics change.
|
||||||
var pickKindLabels = map[string]string{
|
var pickKindLabels = map[string]string{
|
||||||
"taste": "Taste picks",
|
"taste": "Taste picks",
|
||||||
"fresh": "Fresh picks",
|
"fresh": "Fresh picks",
|
||||||
"dormant": "Dormant artists",
|
"dormant": "Dormant artists",
|
||||||
"cross_user": "Liked by others",
|
"taste_unheard": "Taste-matched",
|
||||||
"random": "Random unheard",
|
"cross_user": "Liked by others",
|
||||||
"tier1": "Tier 1 (exact)",
|
"random": "Random unheard",
|
||||||
"tier2": "Tier 2 (relaxed)",
|
"tier1": "Tier 1 (exact)",
|
||||||
"tier3": "Tier 3 (stretched)",
|
"tier2": "Tier 2 (relaxed)",
|
||||||
|
"tier3": "Tier 3 (stretched)",
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
|
// 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
|
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
|
||||||
// vocabulary, so iterating the list is exhaustive.
|
// vocabulary, so iterating the list is exhaustive.
|
||||||
var pickKindOrder = []string{
|
var pickKindOrder = []string{
|
||||||
"taste", "fresh", "dormant", "cross_user", "random",
|
"taste", "fresh", "dormant", "taste_unheard", "cross_user", "random",
|
||||||
"tier1", "tier2", "tier3", "",
|
"tier1", "tier2", "tier3", "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
|
|||||||
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
|
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
|
||||||
// Provenance is standard (#1270): Discover's bucket stamps surface as
|
// Provenance is standard (#1270): Discover's bucket stamps surface as
|
||||||
// a breakdown exactly like For You's taste/fresh — this is what makes
|
// 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 }
|
src := func(s string) *string { return &s }
|
||||||
kind := func(s string) *string { return &s }
|
kind := func(s string) *string { return &s }
|
||||||
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -81,6 +82,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
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"))
|
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
||||||
limits := recommendation.DefaultCandidateSourceLimits()
|
limits := recommendation.DefaultCandidateSourceLimits()
|
||||||
@@ -150,6 +154,24 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
|
|||||||
return v
|
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
|
// parseExcludeParam parses a comma-separated list of UUIDs from the
|
||||||
// `exclude` query string, silently dropping malformed entries. Returns
|
// `exclude` query string, silently dropping malformed entries. Returns
|
||||||
// nil for empty or all-malformed input.
|
// nil for empty or all-malformed input.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -211,3 +211,69 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
|
|||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
|
||||||
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
|
FROM tracks t
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
|
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||||
|
WHERE nt.weight > 0
|
||||||
|
AND trim(g_split.g) <> ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.track_id = t.id
|
||||||
|
AND pe.was_skipped = false
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM general_likes gl
|
||||||
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
GROUP BY t.id, t.album_id, t.artist_id
|
||||||
|
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
||||||
|
LIMIT 120
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListTasteUnheardTracksForDiscoverParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Column2 string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListTasteUnheardTracksForDiscoverRow struct {
|
||||||
|
ID pgtype.UUID
|
||||||
|
AlbumID pgtype.UUID
|
||||||
|
ArtistID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Taste-targeted novelty: unheard tracks whose genres overlap the user's
|
||||||
|
// taste-profile tags (taste_profile_tags, #796), ranked by summed tag
|
||||||
|
// weight — "new to you, but your vibe" rather than the crude random arm.
|
||||||
|
// Genres live inline on tracks.genre as a delimited string, split the
|
||||||
|
// same way the radio tag_overlap arm does (regexp_split_to_table on
|
||||||
|
// [;,]). Same exclusion filters as the other buckets. Returns nothing
|
||||||
|
// when the user has no taste tags yet (cold start), so the caller
|
||||||
|
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
||||||
|
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
|
||||||
|
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListTasteUnheardTracksForDiscoverRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListTasteUnheardTracksForDiscoverRow
|
||||||
|
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
||||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, 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
|
WHERE user_id = $1 AND ended_at IS NULL
|
||||||
ORDER BY started_at DESC
|
ORDER BY started_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -80,12 +80,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
|
|||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
&i.PickKind,
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
||||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, 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) {
|
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.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
&i.PickKind,
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -144,19 +146,22 @@ func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPi
|
|||||||
|
|
||||||
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
||||||
INSERT INTO play_events (
|
INSERT INTO play_events (
|
||||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
|
device_class
|
||||||
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
|
) 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 {
|
type InsertPlayEventParams struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
SessionID pgtype.UUID
|
SessionID pgtype.UUID
|
||||||
StartedAt pgtype.Timestamptz
|
StartedAt pgtype.Timestamptz
|
||||||
ClientID *string
|
ClientID *string
|
||||||
Source *string
|
Source *string
|
||||||
PickKind *string
|
PickKind *string
|
||||||
|
DeviceClass *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// pick_kind is non-NULL only for system-playlist plays whose track was
|
// 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.ClientID,
|
||||||
arg.Source,
|
arg.Source,
|
||||||
arg.PickKind,
|
arg.PickKind,
|
||||||
|
arg.DeviceClass,
|
||||||
)
|
)
|
||||||
var i PlayEvent
|
var i PlayEvent
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -188,6 +194,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
|||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
&i.PickKind,
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -254,7 +261,7 @@ func (q *Queries) InsertSkipEvent(ctx context.Context, arg InsertSkipEventParams
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
const listRecentSessionTracks = `-- name: ListRecentSessionTracks :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
|
||||||
JOIN play_events pe ON pe.track_id = t.id
|
JOIN play_events pe ON pe.track_id = t.id
|
||||||
WHERE pe.session_id = $1
|
WHERE pe.session_id = $1
|
||||||
AND pe.started_at < $2
|
AND pe.started_at < $2
|
||||||
@@ -296,6 +303,8 @@ func (q *Queries) ListRecentSessionTracks(ctx context.Context, arg ListRecentSes
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -331,7 +340,7 @@ SET ended_at = $2,
|
|||||||
completion_ratio = $4,
|
completion_ratio = $4,
|
||||||
was_skipped = $5
|
was_skipped = $5
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, 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 {
|
type UpdatePlayEventEndedParams struct {
|
||||||
@@ -369,6 +378,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
|
|||||||
&i.ScrobbledAt,
|
&i.ScrobbledAt,
|
||||||
&i.Source,
|
&i.Source,
|
||||||
&i.PickKind,
|
&i.PickKind,
|
||||||
|
&i.DeviceClass,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
const listUserHistory = `-- name: ListUserHistory :many
|
const listUserHistory = `-- name: ListUserHistory :many
|
||||||
SELECT pe.id AS event_id,
|
SELECT pe.id AS event_id,
|
||||||
pe.started_at,
|
pe.started_at,
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
@@ -77,6 +77,8 @@ func (q *Queries) ListUserHistory(ctx context.Context, arg ListUserHistoryParams
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ func (q *Queries) ListLikedTrackIDs(ctx context.Context, userID pgtype.UUID) ([]
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
const listLikedTrackRows = `-- name: ListLikedTrackRows :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at FROM tracks t
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version FROM tracks t
|
||||||
JOIN general_likes l ON l.track_id = t.id
|
JOIN general_likes l ON l.track_id = t.id
|
||||||
WHERE l.user_id = $1
|
WHERE l.user_id = $1
|
||||||
ORDER BY l.liked_at DESC
|
ORDER BY l.liked_at DESC
|
||||||
@@ -297,6 +297,8 @@ func (q *Queries) ListLikedTrackRows(ctx context.Context, arg ListLikedTrackRows
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-25
@@ -384,6 +384,7 @@ type PlayEvent struct {
|
|||||||
ScrobbledAt pgtype.Timestamptz
|
ScrobbledAt pgtype.Timestamptz
|
||||||
Source *string
|
Source *string
|
||||||
PickKind *string
|
PickKind *string
|
||||||
|
DeviceClass *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlaySession struct {
|
type PlaySession struct {
|
||||||
@@ -446,16 +447,17 @@ type RecommendationTuningAudit struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RecommendationWeightProfile struct {
|
type RecommendationWeightProfile struct {
|
||||||
Profile string
|
Profile string
|
||||||
BaseWeight float64
|
BaseWeight float64
|
||||||
LikeBoost float64
|
LikeBoost float64
|
||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
SimilarityWeight float64
|
SimilarityWeight float64
|
||||||
TasteWeight float64
|
TasteWeight float64
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
ContextTimeWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type RegistrationSetting struct {
|
type RegistrationSetting struct {
|
||||||
@@ -532,6 +534,21 @@ type SystemPlaylistRun struct {
|
|||||||
LastError *string
|
LastError *string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TagProviderSetting struct {
|
||||||
|
ProviderID string
|
||||||
|
Enabled bool
|
||||||
|
ApiKey *string
|
||||||
|
DisplayOrder int32
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
|
type TagSourcesMetum struct {
|
||||||
|
ID bool
|
||||||
|
CurrentVersion int32
|
||||||
|
LastRegisteredProvidersHash string
|
||||||
|
}
|
||||||
|
|
||||||
type TasteProfileArtist struct {
|
type TasteProfileArtist struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
@@ -539,6 +556,20 @@ type TasteProfileArtist struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
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 {
|
type TasteProfileTag struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
Tag string
|
Tag string
|
||||||
@@ -553,24 +584,29 @@ type TasteTuning struct {
|
|||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
EnrichedTagScale float64
|
||||||
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Track struct {
|
type Track struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Title string
|
Title string
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
TrackNumber *int32
|
TrackNumber *int32
|
||||||
DiscNumber *int32
|
DiscNumber *int32
|
||||||
DurationMs int32
|
DurationMs int32
|
||||||
FilePath string
|
FilePath string
|
||||||
FileSize int64
|
FileSize int64
|
||||||
FileFormat string
|
FileFormat string
|
||||||
Bitrate *int32
|
Bitrate *int32
|
||||||
Mbid *string
|
Mbid *string
|
||||||
Genre *string
|
Genre *string
|
||||||
AddedAt pgtype.Timestamptz
|
AddedAt pgtype.Timestamptz
|
||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
TagSource *string
|
||||||
|
TagSourcesVersion int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackSimilarity struct {
|
type TrackSimilarity struct {
|
||||||
@@ -581,6 +617,12 @@ type TrackSimilarity struct {
|
|||||||
FetchedAt pgtype.Timestamptz
|
FetchedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrackTag struct {
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
Tag string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
Username string
|
Username string
|
||||||
|
|||||||
@@ -11,6 +11,110 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
||||||
WITH user_plays AS (
|
WITH user_plays AS (
|
||||||
SELECT t.artist_id, max(pe.started_at) AS last_started
|
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
|
WHERE user_id = $2 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -161,6 +265,8 @@ func (q *Queries) ListMostPlayedTracksForArtist(ctx context.Context, arg ListMos
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -181,7 +287,7 @@ WITH plays AS (
|
|||||||
WHERE user_id = $1 AND was_skipped = false
|
WHERE user_id = $1 AND was_skipped = false
|
||||||
GROUP BY track_id
|
GROUP BY track_id
|
||||||
)
|
)
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM plays p
|
FROM plays p
|
||||||
@@ -240,6 +346,8 @@ func (q *Queries) ListMostPlayedTracksForUser(ctx context.Context, arg ListMostP
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -577,12 +685,14 @@ func (q *Queries) ListRediscoverArtistsForUser(ctx context.Context, arg ListRedi
|
|||||||
|
|
||||||
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
const loadRadioCandidates = `-- name: LoadRadioCandidates :many
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
pe.skip_count
|
pe.skip_count,
|
||||||
|
al.release_date AS release_date
|
||||||
FROM tracks t
|
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 general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -616,6 +726,7 @@ type LoadRadioCandidatesRow struct {
|
|||||||
LastPlayedAt pgtype.Timestamptz
|
LastPlayedAt pgtype.Timestamptz
|
||||||
PlayCount int64
|
PlayCount int64
|
||||||
SkipCount int64
|
SkipCount int64
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns all tracks except the seed and any played by the user within
|
// 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.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
&i.SkipCount,
|
&i.SkipCount,
|
||||||
|
&i.ReleaseDate,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -748,6 +862,22 @@ taste_overlap AS (
|
|||||||
ORDER BY tpa.weight DESC, t.id
|
ORDER BY tpa.weight DESC, t.id
|
||||||
LIMIT $10
|
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 (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -759,16 +889,18 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
UNION SELECT track_id FROM taste_overlap
|
UNION SELECT track_id FROM taste_overlap
|
||||||
|
UNION SELECT track_id FROM coplay_artists
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
LIMIT $9
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
pe.skip_count,
|
pe.skip_count,
|
||||||
|
al.release_date AS release_date,
|
||||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||||
FROM (
|
FROM (
|
||||||
SELECT track_id, sim_score FROM lb_similar
|
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 tag_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM likes_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 taste_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
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 general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT max(started_at) AS last_played_at,
|
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,
|
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.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
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 {
|
type LoadRadioCandidatesV2Params struct {
|
||||||
@@ -804,6 +939,7 @@ type LoadRadioCandidatesV2Params struct {
|
|||||||
Limit_4 int32
|
Limit_4 int32
|
||||||
Limit_5 int32
|
Limit_5 int32
|
||||||
Limit_6 int32
|
Limit_6 int32
|
||||||
|
Limit_7 int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadRadioCandidatesV2Row struct {
|
type LoadRadioCandidatesV2Row struct {
|
||||||
@@ -812,6 +948,7 @@ type LoadRadioCandidatesV2Row struct {
|
|||||||
LastPlayedAt pgtype.Timestamptz
|
LastPlayedAt pgtype.Timestamptz
|
||||||
PlayCount int64
|
PlayCount int64
|
||||||
SkipCount int64
|
SkipCount int64
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
SimilarityScore interface{}
|
SimilarityScore interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,7 +960,9 @@ type LoadRadioCandidatesV2Row struct {
|
|||||||
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||||
// positively-weighted taste-profile artists, so taste-relevant tracks
|
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||||||
// enter the pool even when the similarity/random arms miss them; scored
|
// 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.
|
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
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_4,
|
||||||
arg.Limit_5,
|
arg.Limit_5,
|
||||||
arg.Limit_6,
|
arg.Limit_6,
|
||||||
|
arg.Limit_7,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -862,10 +1002,13 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.IsLiked,
|
&i.IsLiked,
|
||||||
&i.LastPlayedAt,
|
&i.LastPlayedAt,
|
||||||
&i.PlayCount,
|
&i.PlayCount,
|
||||||
&i.SkipCount,
|
&i.SkipCount,
|
||||||
|
&i.ReleaseDate,
|
||||||
&i.SimilarityScore,
|
&i.SimilarityScore,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const getTasteTuning = `-- name: GetTasteTuning :one
|
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) {
|
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.EngagementNeutral,
|
||||||
&i.EngagementFull,
|
&i.EngagementFull,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrichedTagScale,
|
||||||
|
&i.EraScale,
|
||||||
|
&i.MoodScale,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -80,7 +83,7 @@ func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]Recommend
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listWeightProfiles = `-- name: ListWeightProfiles :many
|
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) {
|
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
|
||||||
@@ -103,6 +106,7 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
|
|||||||
&i.SimilarityWeight,
|
&i.SimilarityWeight,
|
||||||
&i.TasteWeight,
|
&i.TasteWeight,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ContextTimeWeight,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -120,9 +124,12 @@ UPDATE taste_tuning
|
|||||||
engagement_hard_skip = $2,
|
engagement_hard_skip = $2,
|
||||||
engagement_neutral = $3,
|
engagement_neutral = $3,
|
||||||
engagement_full = $4,
|
engagement_full = $4,
|
||||||
|
enriched_tag_scale = $5,
|
||||||
|
era_scale = $6,
|
||||||
|
mood_scale = $7,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE singleton = true
|
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 {
|
type UpdateTasteTuningParams struct {
|
||||||
@@ -130,6 +137,9 @@ type UpdateTasteTuningParams struct {
|
|||||||
EngagementHardSkip float64
|
EngagementHardSkip float64
|
||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
|
EnrichedTagScale float64
|
||||||
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
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.EngagementHardSkip,
|
||||||
arg.EngagementNeutral,
|
arg.EngagementNeutral,
|
||||||
arg.EngagementFull,
|
arg.EngagementFull,
|
||||||
|
arg.EnrichedTagScale,
|
||||||
|
arg.EraScale,
|
||||||
|
arg.MoodScale,
|
||||||
)
|
)
|
||||||
var i TasteTuning
|
var i TasteTuning
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -147,35 +160,40 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
|||||||
&i.EngagementNeutral,
|
&i.EngagementNeutral,
|
||||||
&i.EngagementFull,
|
&i.EngagementFull,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.EnrichedTagScale,
|
||||||
|
&i.EraScale,
|
||||||
|
&i.MoodScale,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateWeightProfile = `-- name: UpdateWeightProfile :one
|
const updateWeightProfile = `-- name: UpdateWeightProfile :one
|
||||||
UPDATE recommendation_weight_profiles
|
UPDATE recommendation_weight_profiles
|
||||||
SET base_weight = $2,
|
SET base_weight = $2,
|
||||||
like_boost = $3,
|
like_boost = $3,
|
||||||
recency_weight = $4,
|
recency_weight = $4,
|
||||||
skip_penalty = $5,
|
skip_penalty = $5,
|
||||||
jitter_magnitude = $6,
|
jitter_magnitude = $6,
|
||||||
context_weight = $7,
|
context_weight = $7,
|
||||||
similarity_weight = $8,
|
similarity_weight = $8,
|
||||||
taste_weight = $9,
|
taste_weight = $9,
|
||||||
updated_at = now()
|
context_time_weight = $10,
|
||||||
|
updated_at = now()
|
||||||
WHERE profile = $1
|
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 {
|
type UpdateWeightProfileParams struct {
|
||||||
Profile string
|
Profile string
|
||||||
BaseWeight float64
|
BaseWeight float64
|
||||||
LikeBoost float64
|
LikeBoost float64
|
||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
SimilarityWeight float64
|
SimilarityWeight float64
|
||||||
TasteWeight float64
|
TasteWeight float64
|
||||||
|
ContextTimeWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
|
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.ContextWeight,
|
||||||
arg.SimilarityWeight,
|
arg.SimilarityWeight,
|
||||||
arg.TasteWeight,
|
arg.TasteWeight,
|
||||||
|
arg.ContextTimeWeight,
|
||||||
)
|
)
|
||||||
var i RecommendationWeightProfile
|
var i RecommendationWeightProfile
|
||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
@@ -202,6 +221,7 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
|||||||
&i.SimilarityWeight,
|
&i.SimilarityWeight,
|
||||||
&i.TasteWeight,
|
&i.TasteWeight,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.ContextTimeWeight,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -209,8 +229,9 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
|||||||
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||||
INSERT INTO taste_tuning (
|
INSERT INTO taste_tuning (
|
||||||
singleton, half_life_days, engagement_hard_skip,
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
engagement_neutral, engagement_full
|
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||||
) VALUES (true, $1, $2, $3, $4)
|
mood_scale
|
||||||
|
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (singleton) DO NOTHING
|
ON CONFLICT (singleton) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -219,6 +240,9 @@ type UpsertTasteTuningDefaultsParams struct {
|
|||||||
EngagementHardSkip float64
|
EngagementHardSkip float64
|
||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
|
EnrichedTagScale float64
|
||||||
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
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.EngagementHardSkip,
|
||||||
arg.EngagementNeutral,
|
arg.EngagementNeutral,
|
||||||
arg.EngagementFull,
|
arg.EngagementFull,
|
||||||
|
arg.EnrichedTagScale,
|
||||||
|
arg.EraScale,
|
||||||
|
arg.MoodScale,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -235,21 +262,23 @@ const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
|
|||||||
|
|
||||||
INSERT INTO recommendation_weight_profiles (
|
INSERT INTO recommendation_weight_profiles (
|
||||||
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||||
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
jitter_magnitude, context_weight, similarity_weight, taste_weight,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
context_time_weight
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (profile) DO NOTHING
|
ON CONFLICT (profile) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertWeightProfileDefaultsParams struct {
|
type UpsertWeightProfileDefaultsParams struct {
|
||||||
Profile string
|
Profile string
|
||||||
BaseWeight float64
|
BaseWeight float64
|
||||||
LikeBoost float64
|
LikeBoost float64
|
||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
SimilarityWeight float64
|
SimilarityWeight float64
|
||||||
TasteWeight float64
|
TasteWeight float64
|
||||||
|
ContextTimeWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recommendation tuning lab queries (#1250). Seeding happens via the
|
// 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.ContextWeight,
|
||||||
arg.SimilarityWeight,
|
arg.SimilarityWeight,
|
||||||
arg.TasteWeight,
|
arg.TasteWeight,
|
||||||
|
arg.ContextTimeWeight,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,7 +350,7 @@ SELECT c.artist_id,
|
|||||||
FROM chosen c
|
FROM chosen c
|
||||||
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
||||||
ORDER BY score DESC, c.artist_id
|
ORDER BY score DESC, c.artist_id
|
||||||
LIMIT 5
|
LIMIT 12
|
||||||
`
|
`
|
||||||
|
|
||||||
type PickSeedArtistsRow struct {
|
type PickSeedArtistsRow struct {
|
||||||
@@ -359,7 +359,7 @@ type PickSeedArtistsRow struct {
|
|||||||
Tier int32
|
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
|
// "Songs like X" mixes never silently vanish (#1255): the old hard
|
||||||
// 7-day window emptied the seed pool after a quiet week, and the
|
// 7-day window emptied the seed pool after a quiet week, and the
|
||||||
// daily atomic-replace build then deleted every existing mix until
|
// 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
|
// rule-#131 pick-kind ladder and stamps the built tracks, so metrics
|
||||||
// can compare mixes seeded from fresh vs stale engagement.
|
// can compare mixes seeded from fresh vs stale engagement.
|
||||||
// Score = unskipped-play count + 5 if user has liked the artist. The
|
// Score = unskipped-play count + 5 if user has liked the artist. The
|
||||||
// Go-side picker (pickSeedArtistsForDay) shuffles the 5
|
// Go-side picker (pickSeedArtistsForDay) shuffles the 12
|
||||||
// daily-deterministically and takes 3 so the mix set rotates.
|
// daily-deterministically and takes songsLikeSeedCount (6) so the mix
|
||||||
|
// set both rotates day-to-day and fills the dedicated Home row (#1491).
|
||||||
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
|
func (q *Queries) PickSeedArtists(ctx context.Context, userID pgtype.UUID) ([]PickSeedArtistsRow, error) {
|
||||||
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
|
rows, err := q.db.Query(ctx, pickSeedArtists, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -20,6 +20,24 @@ func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID p
|
|||||||
return err
|
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
|
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
|
||||||
DELETE FROM taste_profile_tags WHERE user_id = $1
|
DELETE FROM taste_profile_tags WHERE user_id = $1
|
||||||
`
|
`
|
||||||
@@ -45,6 +63,38 @@ func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteP
|
|||||||
return err
|
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
|
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
|
||||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -87,19 +137,23 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
||||||
SELECT t.artist_id, t.genre
|
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
|
||||||
FROM general_likes gl
|
FROM general_likes gl
|
||||||
JOIN tracks t ON t.id = gl.track_id
|
JOIN tracks t ON t.id = gl.track_id
|
||||||
|
JOIN albums a ON a.id = t.album_id
|
||||||
WHERE gl.user_id = $1
|
WHERE gl.user_id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListLikedTrackTasteInputsForUserRow struct {
|
type ListLikedTrackTasteInputsForUserRow struct {
|
||||||
ArtistID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
Genre *string
|
ArtistID pgtype.UUID
|
||||||
|
Genre *string
|
||||||
|
ReleaseDate pgtype.Date
|
||||||
}
|
}
|
||||||
|
|
||||||
// (artist_id, genre) for each track the user has explicitly liked. Feeds the
|
// (track_id, artist_id, genre, release_date) for each track the user has
|
||||||
// track-like bonus into the liked track's artist and tags.
|
// 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) {
|
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -109,7 +163,12 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
|||||||
var items []ListLikedTrackTasteInputsForUserRow
|
var items []ListLikedTrackTasteInputsForUserRow
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListLikedTrackTasteInputsForUserRow
|
var i ListLikedTrackTasteInputsForUserRow
|
||||||
if err := rows.Scan(&i.ArtistID, &i.Genre); err != nil {
|
if err := rows.Scan(
|
||||||
|
&i.TrackID,
|
||||||
|
&i.ArtistID,
|
||||||
|
&i.Genre,
|
||||||
|
&i.ReleaseDate,
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items = append(items, i)
|
items = append(items, i)
|
||||||
@@ -123,8 +182,10 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
|||||||
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
|
const listPlayEngagementInputsForUser = `-- name: ListPlayEngagementInputsForUser :many
|
||||||
|
|
||||||
SELECT
|
SELECT
|
||||||
|
t.id AS track_id,
|
||||||
t.artist_id,
|
t.artist_id,
|
||||||
t.genre,
|
t.genre,
|
||||||
|
a.release_date,
|
||||||
LEAST(GREATEST(
|
LEAST(GREATEST(
|
||||||
COALESCE(pe.completion_ratio,
|
COALESCE(pe.completion_ratio,
|
||||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
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
|
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
JOIN albums a ON a.id = t.album_id
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -146,10 +208,12 @@ type ListPlayEngagementInputsForUserParams struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ListPlayEngagementInputsForUserRow struct {
|
type ListPlayEngagementInputsForUserRow struct {
|
||||||
ArtistID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
Genre *string
|
ArtistID pgtype.UUID
|
||||||
Completion float64
|
Genre *string
|
||||||
AgeDays float64
|
ReleaseDate pgtype.Date
|
||||||
|
Completion float64
|
||||||
|
AgeDays float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
|
// 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
|
// One row per play in the decay-relevant window. completion is the play's
|
||||||
// completion ratio (precomputed column when present, else duration_played /
|
// completion ratio (precomputed column when present, else duration_played /
|
||||||
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||||
// is split into tags in Go. Quarantined tracks are excluded.
|
// is split into tags in Go; 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) {
|
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
|
||||||
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
|
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -169,8 +234,10 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var i ListPlayEngagementInputsForUserRow
|
var i ListPlayEngagementInputsForUserRow
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
|
&i.TrackID,
|
||||||
&i.ArtistID,
|
&i.ArtistID,
|
||||||
&i.Genre,
|
&i.Genre,
|
||||||
|
&i.ReleaseDate,
|
||||||
&i.Completion,
|
&i.Completion,
|
||||||
&i.AgeDays,
|
&i.AgeDays,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -223,6 +290,84 @@ func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTa
|
|||||||
return items, nil
|
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
|
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
|
||||||
SELECT tag, weight
|
SELECT tag, weight
|
||||||
FROM taste_profile_tags
|
FROM taste_profile_tags
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ func (q *Queries) DeleteTrack(ctx context.Context, id pgtype.UUID) (DeleteTrackR
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getTrackByID = `-- name: GetTrackByID :one
|
const getTrackByID = `-- name: GetTrackByID :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, error) {
|
||||||
@@ -112,12 +112,14 @@ func (q *Queries) GetTrackByID(ctx context.Context, id pgtype.UUID) (Track, erro
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTrackByPath = `-- name: GetTrackByPath :one
|
const getTrackByPath = `-- name: GetTrackByPath :one
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE file_path = $1
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE file_path = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, error) {
|
||||||
@@ -139,12 +141,14 @@ func (q *Queries) GetTrackByPath(ctx context.Context, filePath string) (Track, e
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
const getTracksByIDs = `-- name: GetTracksByIDs :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks WHERE id = ANY($1::uuid[])
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks WHERE id = ANY($1::uuid[])
|
||||||
`
|
`
|
||||||
|
|
||||||
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
// Batched lookup used by /api/library/sync to hydrate upsert payloads
|
||||||
@@ -174,6 +178,8 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -186,7 +192,7 @@ func (q *Queries) GetTracksByIDs(ctx context.Context, dollar_1 []pgtype.UUID) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
const listArtistTracksForUser = `-- name: ListArtistTracksForUser :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -242,6 +248,8 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -256,7 +264,7 @@ func (q *Queries) ListArtistTracksForUser(ctx context.Context, arg ListArtistTra
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
const listRandomTracksForUser = `-- name: ListRandomTracksForUser :many
|
||||||
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at,
|
SELECT t.id, t.title, t.album_id, t.artist_id, t.track_number, t.disc_number, t.duration_ms, t.file_path, t.file_size, t.file_format, t.bitrate, t.mbid, t.genre, t.added_at, t.updated_at, t.tag_source, t.tag_sources_version,
|
||||||
albums.title AS album_title,
|
albums.title AS album_title,
|
||||||
artists.name AS artist_name
|
artists.name AS artist_name
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -309,6 +317,8 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
&i.Track.Genre,
|
&i.Track.Genre,
|
||||||
&i.Track.AddedAt,
|
&i.Track.AddedAt,
|
||||||
&i.Track.UpdatedAt,
|
&i.Track.UpdatedAt,
|
||||||
|
&i.Track.TagSource,
|
||||||
|
&i.Track.TagSourcesVersion,
|
||||||
&i.AlbumTitle,
|
&i.AlbumTitle,
|
||||||
&i.ArtistName,
|
&i.ArtistName,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -323,7 +333,7 @@ func (q *Queries) ListRandomTracksForUser(ctx context.Context, arg ListRandomTra
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
const listTracksByAlbum = `-- name: ListTracksByAlbum :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
|
||||||
WHERE album_id = $1
|
WHERE album_id = $1
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -365,6 +375,8 @@ func (q *Queries) ListTracksByAlbum(ctx context.Context, arg ListTracksByAlbumPa
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -412,7 +424,7 @@ func (q *Queries) ListTracksMissingMbidWithPath(ctx context.Context, limit int32
|
|||||||
}
|
}
|
||||||
|
|
||||||
const searchTracks = `-- name: SearchTracks :many
|
const searchTracks = `-- name: SearchTracks :many
|
||||||
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at FROM tracks
|
SELECT id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version FROM tracks
|
||||||
WHERE title ILIKE '%' || $1::text || '%'
|
WHERE title ILIKE '%' || $1::text || '%'
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM lidarr_quarantine q
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
@@ -461,6 +473,8 @@ func (q *Queries) SearchTracks(ctx context.Context, arg SearchTracksParams) ([]T
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -508,7 +522,7 @@ ON CONFLICT (file_path) DO UPDATE SET
|
|||||||
mbid = EXCLUDED.mbid,
|
mbid = EXCLUDED.mbid,
|
||||||
genre = EXCLUDED.genre,
|
genre = EXCLUDED.genre,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at
|
RETURNING id, title, album_id, artist_id, track_number, disc_number, duration_ms, file_path, file_size, file_format, bitrate, mbid, genre, added_at, updated_at, tag_source, tag_sources_version
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertTrackParams struct {
|
type UpsertTrackParams struct {
|
||||||
@@ -559,6 +573,8 @@ func (q *Queries) UpsertTrack(ctx context.Context, arg UpsertTrackParams) (Track
|
|||||||
&i.Genre,
|
&i.Genre,
|
||||||
&i.AddedAt,
|
&i.AddedAt,
|
||||||
&i.UpdatedAt,
|
&i.UpdatedAt,
|
||||||
|
&i.TagSource,
|
||||||
|
&i.TagSourcesVersion,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Reverse 0041_discover_taste_unheard.up.sql.
|
||||||
|
--
|
||||||
|
-- Null out any rows stamped with the new value before shrinking the
|
||||||
|
-- CHECK back to the 0039 vocabulary, or the ADD CONSTRAINT would fail
|
||||||
|
-- on existing 'taste_unheard' rows (mirrors 0039's down).
|
||||||
|
UPDATE play_events SET pick_kind = NULL WHERE pick_kind = 'taste_unheard';
|
||||||
|
UPDATE playlist_tracks SET pick_kind = NULL WHERE pick_kind = 'taste_unheard';
|
||||||
|
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
DROP CONSTRAINT playlist_tracks_pick_kind_check;
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
ADD CONSTRAINT playlist_tracks_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE play_events
|
||||||
|
DROP CONSTRAINT play_events_pick_kind_check;
|
||||||
|
ALTER TABLE play_events
|
||||||
|
ADD CONSTRAINT play_events_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Discover taste-targeted novelty bucket (milestone #127, Scribe #1252/#1488).
|
||||||
|
--
|
||||||
|
-- The 2-week metrics review found Discover beating the manual baseline on
|
||||||
|
-- skip rate — which for a discovery surface means it is playing it safe:
|
||||||
|
-- on a single-user server it is effectively dormant + crude-random, and
|
||||||
|
-- the per-user taste profile (n_tags, #796) went unused. This adds a
|
||||||
|
-- fourth Discover candidate bucket that ranks unheard tracks by the
|
||||||
|
-- user's taste-profile tag weights ("novelty that fits your vibe"), so
|
||||||
|
-- its picks need a new pick_kind provenance value.
|
||||||
|
--
|
||||||
|
-- Postgres CHECK whitelists can't be altered in place: DROP + re-ADD with
|
||||||
|
-- the expanded list, in the same migration (family rule — new CHECK-enum
|
||||||
|
-- value needs a same-change migration).
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
DROP CONSTRAINT playlist_tracks_pick_kind_check;
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
ADD CONSTRAINT playlist_tracks_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random', 'taste_unheard',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE play_events
|
||||||
|
DROP CONSTRAINT play_events_pick_kind_check;
|
||||||
|
ALTER TABLE play_events
|
||||||
|
ADD CONSTRAINT play_events_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random', 'taste_unheard',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Reverse 0042_track_tags.up.sql.
|
||||||
|
DROP INDEX IF EXISTS tracks_tag_source_idx;
|
||||||
|
ALTER TABLE tracks DROP COLUMN IF EXISTS tag_sources_version;
|
||||||
|
ALTER TABLE tracks DROP COLUMN IF EXISTS tag_source;
|
||||||
|
DROP TABLE IF EXISTS track_tags;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Track-level folksonomy tags for taste enrichment (milestone #160, #1490).
|
||||||
|
--
|
||||||
|
-- A global (not per-user) cache of style/mood tags fetched from
|
||||||
|
-- MusicBrainz (keyless) + Last.fm (when a key is configured), keyed by
|
||||||
|
-- track. Feeds the taste recompute's tag facet alongside raw ID3 genre,
|
||||||
|
-- lifting resolution from coarse "Rock" to "post-punk / shoegaze /
|
||||||
|
-- melancholic". The enricher caps to the top-K tags per track at write
|
||||||
|
-- time so a heavily-tagged track can't dominate the profile. weight is a
|
||||||
|
-- normalized folksonomy strength in [0,1]. Mirrors the coverart
|
||||||
|
-- enricher's versioned source-tracking pattern.
|
||||||
|
CREATE TABLE track_tags (
|
||||||
|
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
tag text NOT NULL,
|
||||||
|
weight double precision NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (track_id, tag)
|
||||||
|
);
|
||||||
|
CREATE INDEX track_tags_track_idx ON track_tags (track_id);
|
||||||
|
|
||||||
|
-- Enrichment bookkeeping (mirror artists.artist_art_source):
|
||||||
|
-- tag_source NULL → not yet processed (eligible)
|
||||||
|
-- tag_source 'lastfm' / 'musicbrainz' / 'mixed' → found, cached
|
||||||
|
-- tag_source 'none' → providers returned nothing
|
||||||
|
-- tag_sources_version → bump to re-process none/stale
|
||||||
|
ALTER TABLE tracks ADD COLUMN tag_source text;
|
||||||
|
ALTER TABLE tracks ADD COLUMN tag_sources_version integer NOT NULL DEFAULT 0;
|
||||||
|
CREATE INDEX tracks_tag_source_idx ON tracks (tag_source, tag_sources_version);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Reverse 0043_tag_provider_settings.up.sql.
|
||||||
|
DROP TABLE IF EXISTS tag_sources_meta;
|
||||||
|
DROP TABLE IF EXISTS tag_provider_settings;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Tag-enrichment provider settings (milestone #160, #1490 Step 2).
|
||||||
|
--
|
||||||
|
-- Mirrors the cover-art provider-settings substrate (migration 0018) so
|
||||||
|
-- adding a new tag source later is "register a provider + it auto-upserts
|
||||||
|
-- a row here" — never a schema change. The tag enricher reads the enabled
|
||||||
|
-- set + api keys from tag_provider_settings and stamps the current version
|
||||||
|
-- from tag_sources_meta onto tracks.tag_sources_version (added in 0042).
|
||||||
|
--
|
||||||
|
-- current_version starts at 1 while tracks.tag_sources_version defaults to
|
||||||
|
-- 0 (migration 0042), so every existing track is "stale" relative to
|
||||||
|
-- current and gets enriched on the first pass after this migrates.
|
||||||
|
|
||||||
|
CREATE TABLE tag_provider_settings (
|
||||||
|
provider_id text PRIMARY KEY,
|
||||||
|
enabled boolean NOT NULL DEFAULT true,
|
||||||
|
api_key text,
|
||||||
|
display_order int NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE tag_sources_meta (
|
||||||
|
id boolean PRIMARY KEY DEFAULT true,
|
||||||
|
current_version int NOT NULL DEFAULT 1,
|
||||||
|
last_registered_providers_hash text NOT NULL DEFAULT '',
|
||||||
|
CONSTRAINT tag_sources_meta_singleton CHECK (id = true)
|
||||||
|
);
|
||||||
|
INSERT INTO tag_sources_meta (id, current_version) VALUES (true, 1);
|
||||||
|
|
||||||
|
-- Seed the v1 providers. MusicBrainz is the keyless always-on default;
|
||||||
|
-- Last.fm ships disabled and is opt-in once the operator supplies a key
|
||||||
|
-- (Step 4 Settings UI), per the no-coercive-settings rule (#26). The
|
||||||
|
-- SettingsService also upserts these at boot, so the seed is belt-and-
|
||||||
|
-- suspenders / documents the intended defaults.
|
||||||
|
INSERT INTO tag_provider_settings (provider_id, enabled, display_order) VALUES
|
||||||
|
('musicbrainz', true, 0),
|
||||||
|
('lastfm', false, 1);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Reverse 0044_taste_tuning_enriched_tag_scale.up.sql.
|
||||||
|
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS enriched_tag_scale;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Expose EnrichedTagScale in the tuning lab (#1520). Adds the enriched-tag
|
||||||
|
-- weight knob to the taste_tuning singleton so operators can dial how much
|
||||||
|
-- folksonomy tags (#1490) count vs raw ID3 genre. DEFAULT 0.5 backfills the
|
||||||
|
-- existing row and matches taste.DefaultConfig().EnrichedTagScale.
|
||||||
|
ALTER TABLE taste_tuning
|
||||||
|
ADD COLUMN enriched_tag_scale double precision NOT NULL DEFAULT 0.5;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS era_scale;
|
||||||
|
DROP TABLE IF EXISTS taste_profile_eras;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 0045_taste_profile_eras.up.sql — era/decade taste facet (#1530,
|
||||||
|
-- milestone #160 Opt 2). A third taste facet alongside artists + tags:
|
||||||
|
-- signed weights over decade buckets ("1990s") derived from
|
||||||
|
-- albums.release_date, rebuilt daily by internal/taste next to the
|
||||||
|
-- artist/tag facets. Weight is a signed float — positive = drawn to
|
||||||
|
-- that era, negative = passively avoided; magnitude reflects decayed
|
||||||
|
-- engagement. Consumed by the recommendation scorer's taste match.
|
||||||
|
-- Mirrors taste_profile_tags: CASCADE on user delete, indexed by
|
||||||
|
-- (user_id, weight DESC) so "top eras" reads stay cheap.
|
||||||
|
CREATE TABLE taste_profile_eras (
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
era text NOT NULL,
|
||||||
|
weight double precision NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, era)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX taste_profile_eras_user_weight_idx
|
||||||
|
ON taste_profile_eras (user_id, weight DESC);
|
||||||
|
|
||||||
|
-- Era-facet build knob, tunable in the lab (mirrors enriched_tag_scale,
|
||||||
|
-- #1520): scales how strongly a decade-play imprints on the profile.
|
||||||
|
-- DEFAULT 0.5 backfills the existing singleton and matches
|
||||||
|
-- taste.DefaultConfig().EraScale; 0 disables the era facet entirely.
|
||||||
|
ALTER TABLE taste_tuning
|
||||||
|
ADD COLUMN era_scale double precision NOT NULL DEFAULT 0.5;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE recommendation_weight_profiles DROP COLUMN IF EXISTS context_time_weight;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- 0046_context_time_weight.up.sql — time-of-day/weekday context conditioning
|
||||||
|
-- (#1531, milestone #160 Opt 3). Adds a per-profile scoring weight for the new
|
||||||
|
-- context-affinity term: how strongly a candidate is boosted when its artist's
|
||||||
|
-- play history concentrates in the current daypart × weekday-type cell (in the
|
||||||
|
-- user's local timezone). Mirrors the other ScoringWeights columns.
|
||||||
|
--
|
||||||
|
-- DEFAULT 1.0 backfills both existing profile rows to a modest on-value; the
|
||||||
|
-- Go ShippedRadioWeights/ShippedDailyMixWeights carry the same 1.0 so fresh
|
||||||
|
-- installs seed identically. Reconcile only seeds MISSING rows (ON CONFLICT DO
|
||||||
|
-- NOTHING), so existing rows rely on this DEFAULT until an operator resets.
|
||||||
|
ALTER TABLE recommendation_weight_profiles
|
||||||
|
ADD COLUMN context_time_weight double precision NOT NULL DEFAULT 1.0;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE taste_tuning DROP COLUMN IF EXISTS mood_scale;
|
||||||
|
DROP TABLE IF EXISTS taste_profile_moods;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
-- 0047_taste_profile_moods.up.sql — mood taste facet (#1534, milestone #160
|
||||||
|
-- Opt 2b). A fourth taste facet alongside artists + tags + eras: signed weights
|
||||||
|
-- over canonical mood buckets (melancholic / energetic / chill / …) derived
|
||||||
|
-- from a track's enriched folksonomy tags (#1490) via the internal/mood
|
||||||
|
-- vocabulary, rebuilt daily. Weight is a signed float — positive = drawn to
|
||||||
|
-- that mood, negative = passively avoided. Coverage is partial (grows with tag
|
||||||
|
-- enrichment), so it's a supplement, not a foundation. Mirrors
|
||||||
|
-- taste_profile_tags: CASCADE on user delete, indexed by (user_id, weight DESC).
|
||||||
|
CREATE TABLE taste_profile_moods (
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
mood text NOT NULL,
|
||||||
|
weight double precision NOT NULL,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, mood)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX taste_profile_moods_user_weight_idx
|
||||||
|
ON taste_profile_moods (user_id, weight DESC);
|
||||||
|
|
||||||
|
-- Mood-facet build knob, tunable in the lab (mirrors era_scale / enriched_tag_
|
||||||
|
-- scale): scales how strongly a mood-tagged play imprints on the profile.
|
||||||
|
-- DEFAULT 0.5 backfills the existing singleton and matches
|
||||||
|
-- taste.DefaultConfig().MoodScale; 0 disables the mood facet entirely.
|
||||||
|
ALTER TABLE taste_tuning
|
||||||
|
ADD COLUMN mood_scale double precision NOT NULL DEFAULT 0.5;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE play_events DROP COLUMN IF EXISTS device_class;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- 0048_play_events_device_class.up.sql — device-class context conditioning
|
||||||
|
-- (#1551, milestone #160 Opt 3b). Records which class of device a play came
|
||||||
|
-- from (mobile / web / …), sent by the client on play-start and normalized
|
||||||
|
-- server-side. Extends the #1531 time-of-day/weekday affinity cell to
|
||||||
|
-- (daypart × weekday × device) for the radio path. Nullable + no CHECK: values
|
||||||
|
-- are normalized in Go (avoids a CHECK-migration each time a client class is
|
||||||
|
-- added, rule #36), and historical rows stay NULL → excluded from the device
|
||||||
|
-- dimension (a graceful cold-start; the facet ramps as new plays land).
|
||||||
|
ALTER TABLE play_events ADD COLUMN device_class text;
|
||||||
@@ -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;
|
||||||
@@ -102,3 +102,37 @@ SELECT t.id, t.album_id, t.artist_id
|
|||||||
)
|
)
|
||||||
ORDER BY md5(t.id::text || $2::text)
|
ORDER BY md5(t.id::text || $2::text)
|
||||||
LIMIT 200;
|
LIMIT 200;
|
||||||
|
|
||||||
|
-- name: ListTasteUnheardTracksForDiscover :many
|
||||||
|
-- Taste-targeted novelty: unheard tracks whose genres overlap the user's
|
||||||
|
-- taste-profile tags (taste_profile_tags, #796), ranked by summed tag
|
||||||
|
-- weight — "new to you, but your vibe" rather than the crude random arm.
|
||||||
|
-- Genres live inline on tracks.genre as a delimited string, split the
|
||||||
|
-- same way the radio tag_overlap arm does (regexp_split_to_table on
|
||||||
|
-- [;,]). Same exclusion filters as the other buckets. Returns nothing
|
||||||
|
-- when the user has no taste tags yet (cold start), so the caller
|
||||||
|
-- redistributes its slots to the other buckets. Stamped 'taste_unheard'.
|
||||||
|
-- $1 = user_id, $2 = date string for md5 tiebreak ordering.
|
||||||
|
SELECT t.id, t.album_id, t.artist_id
|
||||||
|
FROM tracks t
|
||||||
|
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
|
||||||
|
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
|
||||||
|
WHERE nt.weight > 0
|
||||||
|
AND trim(g_split.g) <> ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.track_id = t.id
|
||||||
|
AND pe.was_skipped = false
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM general_likes gl
|
||||||
|
WHERE gl.user_id = $1 AND gl.track_id = t.id
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM lidarr_quarantine q
|
||||||
|
WHERE q.user_id = $1 AND q.track_id = t.id
|
||||||
|
)
|
||||||
|
GROUP BY t.id, t.album_id, t.artist_id
|
||||||
|
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
|
||||||
|
LIMIT 120;
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ LIMIT 1;
|
|||||||
-- found (with a stamped kind) in the user's live snapshot for that
|
-- found (with a stamped kind) in the user's live snapshot for that
|
||||||
-- variant at ingestion time (#1249, generalized in #1270).
|
-- variant at ingestion time (#1249, generalized in #1270).
|
||||||
INSERT INTO play_events (
|
INSERT INTO play_events (
|
||||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
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)
|
device_class
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text,
|
||||||
|
sqlc.narg(device_class)::text)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetSystemPickKindForTrack :one
|
-- name: GetSystemPickKindForTrack :one
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ SELECT
|
|||||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
pe.skip_count
|
pe.skip_count,
|
||||||
|
al.release_date AS release_date
|
||||||
FROM tracks t
|
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 general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -40,7 +42,9 @@ WHERE t.id <> $2
|
|||||||
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||||
-- positively-weighted taste-profile artists, so taste-relevant tracks
|
-- positively-weighted taste-profile artists, so taste-relevant tracks
|
||||||
-- enter the pool even when the similarity/random arms miss them; scored
|
-- 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.
|
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
|
|
||||||
WITH
|
WITH
|
||||||
@@ -124,6 +128,22 @@ taste_overlap AS (
|
|||||||
ORDER BY tpa.weight DESC, t.id
|
ORDER BY tpa.weight DESC, t.id
|
||||||
LIMIT $10
|
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 (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -135,6 +155,7 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
UNION SELECT track_id FROM taste_overlap
|
UNION SELECT track_id FROM taste_overlap
|
||||||
|
UNION SELECT track_id FROM coplay_artists
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
LIMIT $9
|
||||||
@@ -145,6 +166,7 @@ SELECT
|
|||||||
pe.last_played_at::timestamptz AS last_played_at,
|
pe.last_played_at::timestamptz AS last_played_at,
|
||||||
pe.play_count,
|
pe.play_count,
|
||||||
pe.skip_count,
|
pe.skip_count,
|
||||||
|
al.release_date AS release_date,
|
||||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||||
FROM (
|
FROM (
|
||||||
SELECT track_id, sim_score FROM lb_similar
|
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 tag_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM likes_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 taste_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
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 general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT max(started_at) AS last_played_at,
|
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,
|
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.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
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
|
-- name: SuggestArtistsForUser :many
|
||||||
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
-- doesn't exist yet. Never overwrites operator-tuned values.
|
-- doesn't exist yet. Never overwrites operator-tuned values.
|
||||||
INSERT INTO recommendation_weight_profiles (
|
INSERT INTO recommendation_weight_profiles (
|
||||||
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||||
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
jitter_magnitude, context_weight, similarity_weight, taste_weight,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
context_time_weight
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
ON CONFLICT (profile) DO NOTHING;
|
ON CONFLICT (profile) DO NOTHING;
|
||||||
|
|
||||||
-- name: ListWeightProfiles :many
|
-- name: ListWeightProfiles :many
|
||||||
@@ -15,23 +16,25 @@ SELECT * FROM recommendation_weight_profiles ORDER BY profile;
|
|||||||
|
|
||||||
-- name: UpdateWeightProfile :one
|
-- name: UpdateWeightProfile :one
|
||||||
UPDATE recommendation_weight_profiles
|
UPDATE recommendation_weight_profiles
|
||||||
SET base_weight = $2,
|
SET base_weight = $2,
|
||||||
like_boost = $3,
|
like_boost = $3,
|
||||||
recency_weight = $4,
|
recency_weight = $4,
|
||||||
skip_penalty = $5,
|
skip_penalty = $5,
|
||||||
jitter_magnitude = $6,
|
jitter_magnitude = $6,
|
||||||
context_weight = $7,
|
context_weight = $7,
|
||||||
similarity_weight = $8,
|
similarity_weight = $8,
|
||||||
taste_weight = $9,
|
taste_weight = $9,
|
||||||
updated_at = now()
|
context_time_weight = $10,
|
||||||
|
updated_at = now()
|
||||||
WHERE profile = $1
|
WHERE profile = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: UpsertTasteTuningDefaults :exec
|
-- name: UpsertTasteTuningDefaults :exec
|
||||||
INSERT INTO taste_tuning (
|
INSERT INTO taste_tuning (
|
||||||
singleton, half_life_days, engagement_hard_skip,
|
singleton, half_life_days, engagement_hard_skip,
|
||||||
engagement_neutral, engagement_full
|
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||||
) VALUES (true, $1, $2, $3, $4)
|
mood_scale
|
||||||
|
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (singleton) DO NOTHING;
|
ON CONFLICT (singleton) DO NOTHING;
|
||||||
|
|
||||||
-- name: GetTasteTuning :one
|
-- name: GetTasteTuning :one
|
||||||
@@ -43,6 +46,9 @@ UPDATE taste_tuning
|
|||||||
engagement_hard_skip = $2,
|
engagement_hard_skip = $2,
|
||||||
engagement_neutral = $3,
|
engagement_neutral = $3,
|
||||||
engagement_full = $4,
|
engagement_full = $4,
|
||||||
|
enriched_tag_scale = $5,
|
||||||
|
era_scale = $6,
|
||||||
|
mood_scale = $7,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE singleton = true
|
WHERE singleton = true
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ UPDATE system_playlist_runs
|
|||||||
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
|
UPDATE system_playlist_runs SET in_flight = false WHERE in_flight = true;
|
||||||
|
|
||||||
-- name: PickSeedArtists :many
|
-- name: PickSeedArtists :many
|
||||||
-- Top-5 most-engaged distinct artist candidates, tiered so the
|
-- Top-12 most-engaged distinct artist candidates, tiered so the
|
||||||
-- "Songs like X" mixes never silently vanish (#1255): the old hard
|
-- "Songs like X" mixes never silently vanish (#1255): the old hard
|
||||||
-- 7-day window emptied the seed pool after a quiet week, and the
|
-- 7-day window emptied the seed pool after a quiet week, and the
|
||||||
-- daily atomic-replace build then deleted every existing mix until
|
-- 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
|
-- rule-#131 pick-kind ladder and stamps the built tracks, so metrics
|
||||||
-- can compare mixes seeded from fresh vs stale engagement.
|
-- can compare mixes seeded from fresh vs stale engagement.
|
||||||
-- Score = unskipped-play count + 5 if user has liked the artist. The
|
-- Score = unskipped-play count + 5 if user has liked the artist. The
|
||||||
-- Go-side picker (pickSeedArtistsForDay) shuffles the 5
|
-- Go-side picker (pickSeedArtistsForDay) shuffles the 12
|
||||||
-- daily-deterministically and takes 3 so the mix set rotates.
|
-- 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 (
|
WITH liked AS (
|
||||||
SELECT gla.artist_id FROM general_likes_artists gla WHERE gla.user_id = $1
|
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
|
FROM chosen c
|
||||||
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
LEFT JOIN liked l ON l.artist_id = c.artist_id
|
||||||
ORDER BY score DESC, c.artist_id
|
ORDER BY score DESC, c.artist_id
|
||||||
LIMIT 5;
|
LIMIT 12;
|
||||||
|
|
||||||
-- name: PickTopPlayedTracksForUser :many
|
-- name: PickTopPlayedTracksForUser :many
|
||||||
-- For-You candidate seeds, tiered so For-You never silently vanishes:
|
-- For-You candidate seeds, tiered so For-You never silently vanishes:
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -6,10 +6,13 @@
|
|||||||
-- One row per play in the decay-relevant window. completion is the play's
|
-- One row per play in the decay-relevant window. completion is the play's
|
||||||
-- completion ratio (precomputed column when present, else duration_played /
|
-- completion ratio (precomputed column when present, else duration_played /
|
||||||
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||||
-- is split into tags in Go. Quarantined tracks are excluded.
|
-- is split into tags in Go; release_date derives the decade for the era
|
||||||
|
-- facet (#1530). Quarantined tracks are excluded.
|
||||||
SELECT
|
SELECT
|
||||||
|
t.id AS track_id,
|
||||||
t.artist_id,
|
t.artist_id,
|
||||||
t.genre,
|
t.genre,
|
||||||
|
a.release_date,
|
||||||
LEAST(GREATEST(
|
LEAST(GREATEST(
|
||||||
COALESCE(pe.completion_ratio,
|
COALESCE(pe.completion_ratio,
|
||||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
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
|
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||||
FROM play_events pe
|
FROM play_events pe
|
||||||
JOIN tracks t ON t.id = pe.track_id
|
JOIN tracks t ON t.id = pe.track_id
|
||||||
|
JOIN albums a ON a.id = t.album_id
|
||||||
WHERE pe.user_id = $1
|
WHERE pe.user_id = $1
|
||||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
@@ -25,11 +29,13 @@ WHERE pe.user_id = $1
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- name: ListLikedTrackTasteInputsForUser :many
|
-- name: ListLikedTrackTasteInputsForUser :many
|
||||||
-- (artist_id, genre) for each track the user has explicitly liked. Feeds the
|
-- (track_id, artist_id, genre, release_date) for each track the user has
|
||||||
-- track-like bonus into the liked track's artist and tags.
|
-- explicitly liked. Feeds the track-like bonus into the liked track's artist,
|
||||||
SELECT t.artist_id, t.genre
|
-- 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
|
FROM general_likes gl
|
||||||
JOIN tracks t ON t.id = gl.track_id
|
JOIN tracks t ON t.id = gl.track_id
|
||||||
|
JOIN albums a ON a.id = t.album_id
|
||||||
WHERE gl.user_id = $1;
|
WHERE gl.user_id = $1;
|
||||||
|
|
||||||
-- name: ListLikedArtistIDsForUser :many
|
-- name: ListLikedArtistIDsForUser :many
|
||||||
@@ -64,3 +70,33 @@ FROM taste_profile_tags
|
|||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
ORDER BY weight DESC
|
ORDER BY weight DESC
|
||||||
LIMIT $2;
|
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;
|
||||||
|
|||||||
@@ -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[]);
|
||||||
@@ -62,6 +62,10 @@ var dataTables = []string{
|
|||||||
// (never recreates the singleton, seeded once by 0018); ResetDB
|
// (never recreates the singleton, seeded once by 0018); ResetDB
|
||||||
// resets its counter via UPDATE below instead.
|
// resets its counter via UPDATE below instead.
|
||||||
"cover_art_provider_settings",
|
"cover_art_provider_settings",
|
||||||
|
// 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
|
// recsettings.New reconciles shipped defaults on every construction
|
||||||
// (#1250), so truncating gives each test pristine tuning values.
|
// (#1250), so truncating gives each test pristine tuning values.
|
||||||
"recommendation_weight_profiles",
|
"recommendation_weight_profiles",
|
||||||
@@ -98,4 +102,12 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
t.Fatalf("dbtest.ResetDB reset cover-art version: %v", err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,16 @@ func (w *Writer) RecordPlayStarted(
|
|||||||
clientID string,
|
clientID string,
|
||||||
at time.Time,
|
at time.Time,
|
||||||
) (StartedResult, error) {
|
) (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
|
// systemPlaylistSources are the play_events.source values that count
|
||||||
@@ -136,7 +145,7 @@ func lookupSystemPickKind(
|
|||||||
func (w *Writer) RecordPlayStartedWithSource(
|
func (w *Writer) RecordPlayStartedWithSource(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID, trackID pgtype.UUID,
|
userID, trackID pgtype.UUID,
|
||||||
clientID, source string,
|
clientID, source, deviceClass string,
|
||||||
at time.Time,
|
at time.Time,
|
||||||
) (StartedResult, error) {
|
) (StartedResult, error) {
|
||||||
var out StartedResult
|
var out StartedResult
|
||||||
@@ -165,13 +174,14 @@ func (w *Writer) RecordPlayStartedWithSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
TrackID: trackID,
|
TrackID: trackID,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||||
ClientID: clientIDPtr,
|
ClientID: clientIDPtr,
|
||||||
Source: sourcePtr,
|
Source: sourcePtr,
|
||||||
PickKind: pickKind,
|
PickKind: pickKind,
|
||||||
|
DeviceClass: strPtrOrNil(deviceClass),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -389,7 +399,7 @@ func (w *Writer) RecordSyntheticCompletedPlay(
|
|||||||
func (w *Writer) RecordOfflinePlay(
|
func (w *Writer) RecordOfflinePlay(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID, trackID pgtype.UUID,
|
userID, trackID pgtype.UUID,
|
||||||
clientID, source string,
|
clientID, source, deviceClass string,
|
||||||
at time.Time,
|
at time.Time,
|
||||||
durationPlayedMs int32,
|
durationPlayedMs int32,
|
||||||
) error {
|
) error {
|
||||||
@@ -441,13 +451,14 @@ func (w *Writer) RecordOfflinePlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
TrackID: trackID,
|
TrackID: trackID,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||||
ClientID: clientIDPtr,
|
ClientID: clientIDPtr,
|
||||||
Source: sourcePtr,
|
Source: sourcePtr,
|
||||||
PickKind: pickKind,
|
PickKind: pickKind,
|
||||||
|
DeviceClass: strPtrOrNil(deviceClass),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -226,10 +226,10 @@ func TestRecordOfflinePlay_DedupsByUserTrackStartedAt(t *testing.T) {
|
|||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
at := time.Now().UTC()
|
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)
|
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)
|
t.Fatalf("replay offline: %v", err)
|
||||||
}
|
}
|
||||||
var count int
|
var count int
|
||||||
@@ -444,7 +444,7 @@ func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
|
|||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
if _, err := f.w.RecordPlayStartedWithSource(
|
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 {
|
); err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
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).
|
// Re-playing the same track stays a set (no duplicate append).
|
||||||
if _, err := f.w.RecordPlayStartedWithSource(
|
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 {
|
); err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource 2: %v", err)
|
t.Fatalf("RecordPlayStartedWithSource 2: %v", err)
|
||||||
}
|
}
|
||||||
@@ -511,7 +511,7 @@ func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
|
|||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
seedSystemSnapshot(t, f, "for_you", "fresh")
|
seedSystemSnapshot(t, f, "for_you", "fresh")
|
||||||
res, err := f.w.RecordPlayStartedWithSource(
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
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.
|
// out, or no snapshot exists) stays unattributed rather than guessing.
|
||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
res, err := f.w.RecordPlayStartedWithSource(
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
@@ -552,7 +552,7 @@ func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
|
|||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
seedSystemSnapshot(t, f, "for_you", "taste")
|
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||||
res, err := f.w.RecordPlayStartedWithSource(
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
@@ -572,7 +572,7 @@ func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
|
|||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
seedSystemSnapshot(t, f, "discover", "dormant")
|
seedSystemSnapshot(t, f, "discover", "dormant")
|
||||||
res, err := f.w.RecordPlayStartedWithSource(
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
@@ -593,7 +593,7 @@ func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
|
|||||||
seedSystemSnapshot(t, f, "for_you", "taste")
|
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||||
at := time.Now().UTC().Add(-time.Hour)
|
at := time.Now().UTC().Add(-time.Hour)
|
||||||
if err := f.w.RecordOfflinePlay(
|
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 {
|
); err != nil {
|
||||||
t.Fatalf("RecordOfflinePlay: %v", err)
|
t.Fatalf("RecordOfflinePlay: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
discoverTotalSlots = 100
|
discoverTotalSlots = 100
|
||||||
discoverDormantSlots = 40
|
// Bucket allocation, pushed toward *targeted* novelty (Scribe #1488):
|
||||||
discoverCrossUserSlots = 30
|
// the taste-matched-unheard arm gets the plurality, dormant stays a
|
||||||
discoverRandomSlots = 30
|
// familiar anchor, and the crude random arm is trimmed to a
|
||||||
|
// serendipity sliver. On a single-user server cross_user is empty and
|
||||||
|
// its slots redistribute, so taste_unheard becomes the dominant real
|
||||||
|
// arm — Discover actually discovers instead of re-warming known artists.
|
||||||
|
discoverTasteUnheardSlots = 35
|
||||||
|
discoverDormantSlots = 30
|
||||||
|
discoverCrossUserSlots = 20
|
||||||
|
discoverRandomSlots = 15
|
||||||
discoverMaxTracksPerAlbum = 2
|
discoverMaxTracksPerAlbum = 2
|
||||||
discoverMaxTracksPerArtist = 3
|
discoverMaxTracksPerArtist = 3
|
||||||
)
|
)
|
||||||
@@ -21,9 +28,10 @@ const (
|
|||||||
// discoverTrack is the common shape used by the bucket allocator. The
|
// discoverTrack is the common shape used by the bucket allocator. The
|
||||||
// three sqlc-generated row types collapse into this internal struct so
|
// three sqlc-generated row types collapse into this internal struct so
|
||||||
// downstream functions don't need to be generic over them. PickKind is
|
// downstream functions don't need to be generic over them. PickKind is
|
||||||
// the originating bucket (dormant/cross_user/random, #1270), stamped in
|
// the originating bucket (taste_unheard/dormant/cross_user/random,
|
||||||
// the row adapters so it survives the interleave — bucket identity is
|
// #1270/#1488), stamped in the row adapters so it survives the
|
||||||
// what makes the 40/30/30 allocation measurable in the metrics.
|
// interleave — bucket identity is what makes the slot allocation
|
||||||
|
// measurable in the metrics.
|
||||||
type discoverTrack struct {
|
type discoverTrack struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
@@ -32,8 +40,9 @@ type discoverTrack struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildDiscoverCandidates assembles the Discover playlist track list.
|
// buildDiscoverCandidates assembles the Discover playlist track list.
|
||||||
// Pulls from three buckets, applies per-album/per-artist caps, then
|
// Pulls from four buckets (taste_unheard / dormant / cross_user /
|
||||||
// redistributes any deficit equally across the remaining buckets.
|
// random), applies per-album/per-artist caps, then redistributes any
|
||||||
|
// deficit equally across the remaining buckets.
|
||||||
//
|
//
|
||||||
// Returns up to discoverTotalSlots track IDs in the order they should
|
// Returns up to discoverTotalSlots track IDs in the order they should
|
||||||
// appear in the playlist (round-robin interleaved across buckets).
|
// appear in the playlist (round-robin interleaved across buckets).
|
||||||
@@ -43,6 +52,57 @@ type discoverTrack struct {
|
|||||||
// the redistribution algorithm rolls the deficit into the surviving
|
// the redistribution algorithm rolls the deficit into the surviving
|
||||||
// buckets so one broken bucket can't silently kill the whole playlist.
|
// buckets so one broken bucket can't silently kill the whole playlist.
|
||||||
func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) ([]rankedCandidate, error) {
|
func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) ([]rankedCandidate, error) {
|
||||||
|
pools := loadDiscoverPools(ctx, q, logger, userID, dateStr)
|
||||||
|
|
||||||
|
// Allocate slots with redistribution. Order fixes both the allocation
|
||||||
|
// index mapping and (below) the interleave/dedup priority.
|
||||||
|
allocations := redistributeSlots([]bucketRequest{
|
||||||
|
{want: discoverTasteUnheardSlots, available: len(pools.taste)},
|
||||||
|
{want: discoverDormantSlots, available: len(pools.dormant)},
|
||||||
|
{want: discoverCrossUserSlots, available: len(pools.crossUser)},
|
||||||
|
{want: discoverRandomSlots, available: len(pools.random)},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Round-robin interleave so the playlist doesn't front-load one
|
||||||
|
// flavour. taste_unheard leads so a track shared with another bucket
|
||||||
|
// keeps the taste stamp — the targeted-novelty arm stays measurable.
|
||||||
|
out := interleaveBuckets(
|
||||||
|
pools.taste[:allocations[0]],
|
||||||
|
pools.dormant[:allocations[1]],
|
||||||
|
pools.crossUser[:allocations[2]],
|
||||||
|
pools.random[:allocations[3]],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Convert to rankedCandidate (the type insertSystemPlaylist accepts).
|
||||||
|
// Score is unused for Discover — the daily-deterministic md5 ordering
|
||||||
|
// already gave us the ranking.
|
||||||
|
ranked := make([]rankedCandidate, 0, len(out))
|
||||||
|
for _, t := range out {
|
||||||
|
ranked = append(ranked, rankedCandidate{TrackID: t.ID, PickKind: t.PickKind})
|
||||||
|
}
|
||||||
|
return ranked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverPools holds the four Discover candidate buckets after adapting
|
||||||
|
// + per-album/per-artist capping. Bucket query failures degrade to an
|
||||||
|
// empty pool (logged) so one broken bucket can't kill the whole playlist;
|
||||||
|
// the slot redistribution rolls its deficit into the survivors.
|
||||||
|
type discoverPools struct {
|
||||||
|
taste []discoverTrack
|
||||||
|
dormant []discoverTrack
|
||||||
|
crossUser []discoverTrack
|
||||||
|
random []discoverTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDiscoverPools(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) discoverPools {
|
||||||
|
tasteRows, err := q.ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
|
||||||
|
UserID: userID, Column2: dateStr,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("discover: taste-unheard bucket failed; continuing with empty pool",
|
||||||
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
|
tasteRows = nil
|
||||||
|
}
|
||||||
dormantRows, err := q.ListDormantArtistTracksForDiscover(ctx, dbq.ListDormantArtistTracksForDiscoverParams{
|
dormantRows, err := q.ListDormantArtistTracksForDiscover(ctx, dbq.ListDormantArtistTracksForDiscoverParams{
|
||||||
UserID: userID, Column2: dateStr,
|
UserID: userID, Column2: dateStr,
|
||||||
})
|
})
|
||||||
@@ -67,37 +127,23 @@ func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.L
|
|||||||
"user_id", uuidStringPL(userID), "err", err)
|
"user_id", uuidStringPL(userID), "err", err)
|
||||||
randomRows = nil
|
randomRows = nil
|
||||||
}
|
}
|
||||||
|
return discoverPools{
|
||||||
// Adapt sqlc-generated rows into the internal struct, then apply
|
taste: capByAlbumAndArtist(tasteUnheardRowsToTracks(tasteRows)),
|
||||||
// per-album / per-artist caps.
|
dormant: capByAlbumAndArtist(dormantRowsToTracks(dormantRows)),
|
||||||
dormantPool := capByAlbumAndArtist(dormantRowsToTracks(dormantRows))
|
crossUser: capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows)),
|
||||||
crossUserPool := capByAlbumAndArtist(crossUserRowsToTracks(crossUserRows))
|
random: capByAlbumAndArtist(randomRowsToTracks(randomRows)),
|
||||||
randomPool := capByAlbumAndArtist(randomRowsToTracks(randomRows))
|
|
||||||
|
|
||||||
// Allocate slots with redistribution.
|
|
||||||
allocations := redistributeSlots([]bucketRequest{
|
|
||||||
{want: discoverDormantSlots, available: len(dormantPool)},
|
|
||||||
{want: discoverCrossUserSlots, available: len(crossUserPool)},
|
|
||||||
{want: discoverRandomSlots, available: len(randomPool)},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Take the head of each bucket's capped pool.
|
|
||||||
dormantTake := dormantPool[:allocations[0]]
|
|
||||||
crossUserTake := crossUserPool[:allocations[1]]
|
|
||||||
randomTake := randomPool[:allocations[2]]
|
|
||||||
|
|
||||||
// Round-robin interleave so the playlist doesn't front-load one
|
|
||||||
// flavour.
|
|
||||||
out := interleaveBuckets(dormantTake, crossUserTake, randomTake)
|
|
||||||
|
|
||||||
// Convert to rankedCandidate (the type insertSystemPlaylist accepts).
|
|
||||||
// Score is unused for Discover — the daily-deterministic md5 ordering
|
|
||||||
// already gave us the ranking.
|
|
||||||
ranked := make([]rankedCandidate, 0, len(out))
|
|
||||||
for _, t := range out {
|
|
||||||
ranked = append(ranked, rankedCandidate{TrackID: t.ID, PickKind: t.PickKind})
|
|
||||||
}
|
}
|
||||||
return ranked, nil
|
}
|
||||||
|
|
||||||
|
func tasteUnheardRowsToTracks(rows []dbq.ListTasteUnheardTracksForDiscoverRow) []discoverTrack {
|
||||||
|
out := make([]discoverTrack, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
out[i] = discoverTrack{
|
||||||
|
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
||||||
|
PickKind: pickKindTasteUnheard,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
|
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package playlists
|
package playlists
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -98,8 +99,8 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
|
|||||||
|
|
||||||
// pickDailySeeds shuffles the candidate pool daily-deterministically
|
// pickDailySeeds shuffles the candidate pool daily-deterministically
|
||||||
// and takes up to n — For-You uses n=forYouSeedCount for its
|
// and takes up to n — For-You uses n=forYouSeedCount for its
|
||||||
// multi-seed blend (#1269), Songs-like uses n=3 via the
|
// multi-seed blend (#1269), Songs-like uses n=songsLikeSeedCount via
|
||||||
// pickSeedArtistsForDay wrapper. Verifies determinism within a day,
|
// the pickSeedArtistsForDay wrapper. Verifies determinism within a day,
|
||||||
// variation across days, and graceful degradation on small pools.
|
// variation across days, and graceful degradation on small pools.
|
||||||
|
|
||||||
func TestPickDailySeeds_DeterministicWithinDay(t *testing.T) {
|
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
|
// pickSeedArtistsForDay takes the user's top-12 candidate artists and
|
||||||
// returns 3 of them via daily-deterministic shuffle. Verifies the
|
// returns songsLikeSeedCount of them via daily-deterministic shuffle.
|
||||||
// picker is deterministic within a day, varies across days, and
|
// Verifies the picker is deterministic within a day, varies across days,
|
||||||
// degrades gracefully when fewer than 3 or 5 candidates exist.
|
// 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) {
|
func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
pool := []pgtype.UUID{
|
pool := bigSeedPool(10)
|
||||||
{Bytes: [16]byte{10}, Valid: true},
|
|
||||||
{Bytes: [16]byte{20}, Valid: true},
|
|
||||||
{Bytes: [16]byte{30}, Valid: true},
|
|
||||||
{Bytes: [16]byte{40}, Valid: true},
|
|
||||||
{Bytes: [16]byte{50}, Valid: true},
|
|
||||||
}
|
|
||||||
a := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
a := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
||||||
b := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
b := pickSeedArtistsForDay(pool, u, "2026-05-04")
|
||||||
if len(a) != 3 || len(b) != 3 {
|
if len(a) != songsLikeSeedCount || len(b) != songsLikeSeedCount {
|
||||||
t.Fatalf("expected 3 seeds; got %d / %d", len(a), len(b))
|
t.Fatalf("expected %d seeds; got %d / %d", songsLikeSeedCount, len(a), len(b))
|
||||||
}
|
}
|
||||||
for i := range a {
|
for i := range a {
|
||||||
if a[i] != b[i] {
|
if a[i] != b[i] {
|
||||||
@@ -186,40 +201,23 @@ func TestPickSeedArtistsForDay_DeterministicWithinDay(t *testing.T) {
|
|||||||
|
|
||||||
func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) {
|
func TestPickSeedArtistsForDay_VariesAcrossDays(t *testing.T) {
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
pool := []pgtype.UUID{
|
pool := bigSeedPool(10)
|
||||||
{Bytes: [16]byte{10}, Valid: true},
|
// Collect the seed set (order-independent) across many dates. With
|
||||||
{Bytes: [16]byte{20}, Valid: true},
|
// C(10, songsLikeSeedCount) combinations, 30 dates yield >=2 distinct sets.
|
||||||
{Bytes: [16]byte{30}, Valid: true},
|
seen := map[string]bool{}
|
||||||
{Bytes: [16]byte{40}, Valid: true},
|
|
||||||
{Bytes: [16]byte{50}, Valid: true},
|
|
||||||
}
|
|
||||||
// Collect the trio (as a sorted byte tuple) across many dates.
|
|
||||||
// With C(5,3) = 10 possible trios, 30 dates should yield >=2 distinct sets.
|
|
||||||
seen := map[[3]byte]bool{}
|
|
||||||
for i := 1; i <= 30; i++ {
|
for i := 1; i <= 30; i++ {
|
||||||
got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i))
|
got := pickSeedArtistsForDay(pool, u, "2026-05-"+twoDigits(i))
|
||||||
if len(got) != 3 {
|
if len(got) != songsLikeSeedCount {
|
||||||
t.Fatalf("expected 3 seeds; got %d", len(got))
|
t.Fatalf("expected %d seeds; got %d", songsLikeSeedCount, len(got))
|
||||||
}
|
}
|
||||||
// Sort the three byte values for set-equivalence comparison.
|
seen[seedSetKey(got)] = true
|
||||||
v := [3]byte{got[0].Bytes[0], got[1].Bytes[0], got[2].Bytes[0]}
|
|
||||||
if v[0] > v[1] {
|
|
||||||
v[0], v[1] = v[1], v[0]
|
|
||||||
}
|
|
||||||
if v[1] > v[2] {
|
|
||||||
v[1], v[2] = v[2], v[1]
|
|
||||||
}
|
|
||||||
if v[0] > v[1] {
|
|
||||||
v[0], v[1] = v[1], v[0]
|
|
||||||
}
|
|
||||||
seen[v] = true
|
|
||||||
}
|
}
|
||||||
if len(seen) < 2 {
|
if len(seen) < 2 {
|
||||||
t.Errorf("expected >=2 distinct seed trios across 30 days; got %d", len(seen))
|
t.Errorf("expected >=2 distinct seed sets across 30 days; got %d", len(seen))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPickSeedArtistsForDay_FewerThanFive(t *testing.T) {
|
func TestPickSeedArtistsForDay_FewerThanCount(t *testing.T) {
|
||||||
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
u := pgtype.UUID{Bytes: [16]byte{1}, Valid: true}
|
||||||
pool := []pgtype.UUID{
|
pool := []pgtype.UUID{
|
||||||
{Bytes: [16]byte{10}, Valid: true},
|
{Bytes: [16]byte{10}, Valid: true},
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ type seedArtistRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pickSeedArtistsFromRows projects sqlc rows into the seed list. The
|
// pickSeedArtistsFromRows projects sqlc rows into the seed list. The
|
||||||
// SQL already orders by score DESC + artist_id and LIMIT 3, so this is
|
// SQL already orders by score DESC + artist_id and LIMIT 12, so this is
|
||||||
// just a column projection — but pulling it into a function keeps the
|
// just a column projection — but pulling it into a function keeps the
|
||||||
// call-site readable and makes the post-fetch path testable without
|
// call-site readable and makes the post-fetch path testable without
|
||||||
// a database.
|
// a database.
|
||||||
@@ -110,10 +110,20 @@ func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n in
|
|||||||
return shuffled[:n]
|
return shuffled[:n]
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickSeedArtistsForDay picks up to 3 seed artists for the Songs-like
|
// songsLikeSeedCount is how many "Songs like {artist}" mixes to build
|
||||||
// mixes; For-You uses pickDailySeeds directly with forYouSeedCount.
|
// 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 {
|
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
|
// 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
|
// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
|
||||||
// attribute skips to the population that sourced the track — For You's
|
// attribute skips to the population that sourced the track — For You's
|
||||||
// "taste engine missing" vs "freshness tax", Discover's three buckets
|
// "taste engine missing" vs "freshness tax", Discover's three buckets
|
||||||
// (#1270). Values mirror the CHECK in migration 0039; the tier1-3
|
// (#1270). Values mirror the CHECK in migration 0041; taste_unheard is
|
||||||
// values there land with the tiered-mix rebuilds.
|
// Discover's taste-targeted-novelty arm (#1488).
|
||||||
const (
|
const (
|
||||||
pickKindTaste = "taste"
|
pickKindTaste = "taste"
|
||||||
pickKindFresh = "fresh"
|
pickKindFresh = "fresh"
|
||||||
pickKindDormant = "dormant"
|
pickKindDormant = "dormant"
|
||||||
pickKindCrossUser = "cross_user"
|
pickKindTasteUnheard = "taste_unheard"
|
||||||
pickKindRandom = "random"
|
pickKindCrossUser = "cross_user"
|
||||||
pickKindTier1 = "tier1"
|
pickKindRandom = "random"
|
||||||
pickKindTier2 = "tier2"
|
pickKindTier1 = "tier1"
|
||||||
pickKindTier3 = "tier3"
|
pickKindTier2 = "tier2"
|
||||||
|
pickKindTier3 = "tier3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier
|
// 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
|
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
||||||
// while passive avoidance (negative) gently demotes.
|
// while passive avoidance (negative) gently demotes.
|
||||||
TasteWeight: 1.5,
|
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()
|
systemTasteConfig = taste.DefaultConfig()
|
||||||
)
|
)
|
||||||
@@ -532,6 +546,9 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
|||||||
// deep slice of the user's top taste-profile artists into the pool
|
// deep slice of the user's top taste-profile artists into the pool
|
||||||
// (#796 phase 2b). Empty for cold-start users (no profile yet).
|
// (#796 phase 2b). Empty for cold-start users (no profile yet).
|
||||||
TasteOverlap: 80,
|
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
|
return []builtPlaylist{{Name: "For You", Variant: "for_you", Tracks: tracks}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// produceSeedMixes: up to 3 "Songs like {artist}" mixes. Seed
|
// produceSeedMixes: up to songsLikeSeedCount "Songs like {artist}"
|
||||||
// artists rotate daily-deterministically; the seed query falls back
|
// mixes. Seed artists rotate daily-deterministically; the seed query falls back
|
||||||
// through widening engagement windows (#1255) and every returned row
|
// through widening engagement windows (#1255) and every returned row
|
||||||
// shares the winning tier, stamped onto the built tracks as their
|
// shares the winning tier, stamped onto the built tracks as their
|
||||||
// pick_kind. The base seed-artist query failing is fatal; per-artist
|
// 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 +
|
// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
|
||||||
// up to 3 Songs-like-{seed} mixes). Atomic-replace inside one tx;
|
// up to songsLikeSeedCount Songs-like-{seed} mixes). Atomic-replace inside one tx;
|
||||||
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
|
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
|
||||||
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
|
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
||||||
@@ -41,6 +42,11 @@ func LoadCandidates(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]Candidate, 0, len(rows))
|
out := make([]Candidate, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
var lpt *time.Time
|
var lpt *time.Time
|
||||||
@@ -57,7 +63,11 @@ func LoadCandidates(
|
|||||||
PlayCount: int(r.PlayCount),
|
PlayCount: int(r.PlayCount),
|
||||||
SkipCount: int(r.SkipCount),
|
SkipCount: int(r.SkipCount),
|
||||||
ContextualMatchScore: ctxScore,
|
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
|
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
|
||||||
// users have an empty profile, so it contributes nothing anyway).
|
// users have an empty profile, so it contributes nothing anyway).
|
||||||
TasteOverlap int
|
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.
|
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
||||||
@@ -87,6 +101,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
|||||||
LikesOverlap: 20,
|
LikesOverlap: 20,
|
||||||
RandomFill: 30,
|
RandomFill: 30,
|
||||||
TasteOverlap: 20,
|
TasteOverlap: 20,
|
||||||
|
UserCoplay: 20,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +134,7 @@ func LoadCandidatesFromSimilarity(
|
|||||||
Limit_4: int32(limits.LikesOverlap),
|
Limit_4: int32(limits.LikesOverlap),
|
||||||
Limit_5: int32(limits.RandomFill),
|
Limit_5: int32(limits.RandomFill),
|
||||||
Limit_6: int32(limits.TasteOverlap),
|
Limit_6: int32(limits.TasteOverlap),
|
||||||
|
Limit_7: int32(limits.UserCoplay),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -134,6 +150,20 @@ func LoadCandidatesFromSimilarity(
|
|||||||
return nil, err
|
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))
|
out := make([]Candidate, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
var lpt *time.Time
|
var lpt *time.Time
|
||||||
@@ -158,13 +188,42 @@ func LoadCandidatesFromSimilarity(
|
|||||||
SkipCount: int(r.SkipCount),
|
SkipCount: int(r.SkipCount),
|
||||||
ContextualMatchScore: ctxScore,
|
ContextualMatchScore: ctxScore,
|
||||||
SimilarityScore: simScore,
|
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
|
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
|
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
||||||
// one query and groups them by track_id. Rows whose session_vector fails
|
// 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
|
// to unmarshal are skipped with no error (don't poison scoring over one
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contextAffinityShrinkage is the pseudo-count that pulls a low-play artist's
|
||||||
|
// cell-share toward the user's baseline, so an artist with one or two plays
|
||||||
|
// can't swing its affinity to ±1 on noise. At k plays the estimate sits
|
||||||
|
// halfway between the raw cell-share and the baseline.
|
||||||
|
const contextAffinityShrinkage = 5.0
|
||||||
|
|
||||||
|
// ContextAffinity is the read-side map of per-artist time-of-day/weekday
|
||||||
|
// affinity for the CURRENT context (#1531): artist_id → score in [-1, +1].
|
||||||
|
// Absent artists (no play history) score 0, so cold-start candidates stay
|
||||||
|
// neutral. The zero value is a valid all-neutral affinity.
|
||||||
|
type ContextAffinity struct {
|
||||||
|
byArtist map[pgtype.UUID]float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Affinity returns the artist's current-context affinity, or 0 if unknown.
|
||||||
|
func (c ContextAffinity) Affinity(artistID pgtype.UUID) float64 {
|
||||||
|
return c.byArtist[artistID]
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadContextAffinity computes each artist's affinity for the user's CURRENT
|
||||||
|
// context cell — daypart × weekday, narrowed by deviceClass when non-empty
|
||||||
|
// (#1551; radio passes the current device, the daily mixes pass "" for a
|
||||||
|
// device-agnostic cell). For every artist with completed plays in the window it
|
||||||
|
// compares the share of that artist's plays that fall in the current cell
|
||||||
|
// against the user's overall baseline share, shrinking sparse artists toward
|
||||||
|
// the baseline. Returns an empty (all-neutral) affinity when the user has no
|
||||||
|
// plays.
|
||||||
|
func LoadContextAffinity(
|
||||||
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, deviceClass string,
|
||||||
|
) (ContextAffinity, error) {
|
||||||
|
rows, err := q.ListArtistContextPlayCountsForUser(ctx, dbq.ListArtistContextPlayCountsForUserParams{
|
||||||
|
ID: userID,
|
||||||
|
Column2: deviceClass,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return ContextAffinity{}, err
|
||||||
|
}
|
||||||
|
var totalPlays, cellPlays int64
|
||||||
|
for _, r := range rows {
|
||||||
|
totalPlays += r.TotalPlays
|
||||||
|
cellPlays += r.CellPlays
|
||||||
|
}
|
||||||
|
out := ContextAffinity{byArtist: make(map[pgtype.UUID]float64, len(rows))}
|
||||||
|
if totalPlays == 0 {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
baseline := float64(cellPlays) / float64(totalPlays)
|
||||||
|
for _, r := range rows {
|
||||||
|
out.byArtist[r.ArtistID] = contextAffinity(
|
||||||
|
float64(r.CellPlays), float64(r.TotalPlays), baseline, contextAffinityShrinkage)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// contextAffinity returns an artist's shrunk cell-share minus the user's
|
||||||
|
// baseline share, clamped to [-1, 1]. The shrinkage pseudo-count k pulls
|
||||||
|
// low-play artists toward the baseline (→ 0 affinity) so noise can't dominate;
|
||||||
|
// a heavily-played artist keeps close to its raw over/under-representation.
|
||||||
|
func contextAffinity(cellPlays, totalPlays, baseline, k float64) float64 {
|
||||||
|
if totalPlays == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
shrunk := (cellPlays + baseline*k) / (totalPlays + k)
|
||||||
|
return clampUnit(shrunk - baseline)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package recommendation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func insertPlayEventDevice(
|
||||||
|
t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, device string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var sessionID pgtype.UUID
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
|
||||||
|
VALUES ($1, $2, $2, 'ctx-dev-test') RETURNING id`,
|
||||||
|
userID, startedAt).Scan(&sessionID); err != nil {
|
||||||
|
t.Fatalf("insert play_session: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO play_events (user_id, track_id, session_id, started_at, was_skipped, device_class)
|
||||||
|
VALUES ($1, $2, $3, $4, false, $5)`,
|
||||||
|
userID, trackID, sessionID, startedAt, device); err != nil {
|
||||||
|
t.Fatalf("insert play_event: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLoadContextAffinity_DeviceNarrowing seeds one artist played only on
|
||||||
|
// mobile and another only on web (all in the current time cell), and verifies
|
||||||
|
// the current-device parameter narrows the affinity cell (#1551): on 'mobile'
|
||||||
|
// the mobile artist out-scores the web artist and vice-versa, while the
|
||||||
|
// device-agnostic (”) pass treats them equally.
|
||||||
|
func TestLoadContextAffinity_DeviceNarrowing(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
q := dbq.New(pool)
|
||||||
|
|
||||||
|
u := seedUser(t, pool, "ctx-dev")
|
||||||
|
aMobile := seedArtist(t, pool, "MobileArtist", "")
|
||||||
|
aWeb := seedArtist(t, pool, "WebArtist", "")
|
||||||
|
alM := seedAlbumForArtist(t, pool, aMobile.ID, "AlbM")
|
||||||
|
tM := seedTrackOnAlbum(t, pool, alM.ID, aMobile.ID, "TrkM")
|
||||||
|
alW := seedAlbumForArtist(t, pool, aWeb.ID, "AlbW")
|
||||||
|
tW := seedTrackOnAlbum(t, pool, alW.ID, aWeb.ID, "TrkW")
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
insertPlayEventDevice(t, pool, u.ID, tM.ID, now, "mobile")
|
||||||
|
insertPlayEventDevice(t, pool, u.ID, tW.ID, now, "web")
|
||||||
|
}
|
||||||
|
|
||||||
|
mob, err := LoadContextAffinity(ctx, q, u.ID, "mobile")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("mobile: %v", err)
|
||||||
|
}
|
||||||
|
if mob.Affinity(aMobile.ID) <= mob.Affinity(aWeb.ID) {
|
||||||
|
t.Errorf("on mobile, mobile artist (%.3f) should out-score web artist (%.3f)",
|
||||||
|
mob.Affinity(aMobile.ID), mob.Affinity(aWeb.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
web, err := LoadContextAffinity(ctx, q, u.ID, "web")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("web: %v", err)
|
||||||
|
}
|
||||||
|
if web.Affinity(aWeb.ID) <= web.Affinity(aMobile.ID) {
|
||||||
|
t.Errorf("on web, web artist (%.3f) should out-score mobile artist (%.3f)",
|
||||||
|
web.Affinity(aWeb.ID), web.Affinity(aMobile.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device-agnostic ('' ): all plays fall in the same time cell, so the two
|
||||||
|
// artists are treated identically — the device dimension is what separates
|
||||||
|
// them above.
|
||||||
|
agn, err := LoadContextAffinity(ctx, q, u.ID, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("agnostic: %v", err)
|
||||||
|
}
|
||||||
|
if agn.Affinity(aMobile.ID) != agn.Affinity(aWeb.ID) {
|
||||||
|
t.Errorf("device-agnostic affinities should match, got mobile=%.3f web=%.3f",
|
||||||
|
agn.Affinity(aMobile.ID), agn.Affinity(aWeb.ID))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,19 +24,26 @@ type ScoringInputs struct {
|
|||||||
// user's taste, negative reflects passive avoidance, 0 when there's no
|
// user's taste, negative reflects passive avoidance, 0 when there's no
|
||||||
// profile signal (cold start / artist+tags absent from the profile).
|
// profile signal (cold start / artist+tags absent from the profile).
|
||||||
TasteMatchScore float64
|
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
|
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||||
// config.RecommendationConfig and are propagated here per request.
|
// config.RecommendationConfig and are propagated here per request.
|
||||||
type ScoringWeights struct {
|
type ScoringWeights struct {
|
||||||
BaseWeight float64
|
BaseWeight float64
|
||||||
LikeBoost float64
|
LikeBoost float64
|
||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
ContextWeight float64
|
ContextWeight float64
|
||||||
SimilarityWeight float64
|
SimilarityWeight float64
|
||||||
TasteWeight float64
|
TasteWeight float64
|
||||||
|
ContextTimeWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score computes the weighted-shuffle score per spec §6:
|
// Score computes the weighted-shuffle score per spec §6:
|
||||||
@@ -48,6 +55,7 @@ type ScoringWeights struct {
|
|||||||
// + contextual_match_score * ContextWeight
|
// + contextual_match_score * ContextWeight
|
||||||
// + similarity_score * SimilarityWeight
|
// + similarity_score * SimilarityWeight
|
||||||
// + taste_match_score * TasteWeight
|
// + taste_match_score * TasteWeight
|
||||||
|
// + context_affinity_score * ContextTimeWeight
|
||||||
// + small_random_jitter
|
// + small_random_jitter
|
||||||
//
|
//
|
||||||
// Higher score = more likely to surface. rng is a function returning a
|
// 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.ContextualMatchScore * w.ContextWeight
|
||||||
s += in.SimilarityScore * w.SimilarityWeight
|
s += in.SimilarityScore * w.SimilarityWeight
|
||||||
s += in.TasteMatchScore * w.TasteWeight
|
s += in.TasteMatchScore * w.TasteWeight
|
||||||
|
s += in.ContextAffinityScore * w.ContextTimeWeight
|
||||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type SessionVector struct {
|
|||||||
Artists []string `json:"artists"`
|
Artists []string `json:"artists"`
|
||||||
Tags map[string]int `json:"tags"`
|
Tags map[string]int `json:"tags"`
|
||||||
RecentTrackIDs []string `json:"recent_track_ids"`
|
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 {
|
func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package recommendation
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -10,24 +11,37 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Taste-match tuning. The taste profile (written by internal/taste) holds
|
// 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
|
// 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
|
// 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."
|
// 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 (
|
const (
|
||||||
tasteArtistScale = 4.0
|
tasteArtistScale = 4.0
|
||||||
tasteTagScale = 3.0
|
tasteTagScale = 3.0
|
||||||
|
tasteEraScale = 4.0
|
||||||
|
tasteMoodScale = 3.0
|
||||||
tasteArtistShare = 0.7
|
tasteArtistShare = 0.7
|
||||||
tasteTagShare = 0.3
|
tasteTagShare = 0.3
|
||||||
|
tasteEraShare = 0.15
|
||||||
|
tasteMoodShare = 0.12
|
||||||
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
||||||
)
|
)
|
||||||
|
|
||||||
// TasteProfile is the read-side view of a user's learned taste: signed
|
// TasteProfile is the read-side view of a user's learned taste: signed weights
|
||||||
// weights over artists and genre tags. The zero value (and any unknown
|
// over artists, genre tags, decade/era buckets, and mood buckets. The zero
|
||||||
// artist/tag) contributes 0, so cold-start users get no taste effect.
|
// value (and any unknown key) contributes 0, so cold-start users get no taste
|
||||||
|
// effect.
|
||||||
type TasteProfile struct {
|
type TasteProfile struct {
|
||||||
artists map[pgtype.UUID]float64
|
artists map[pgtype.UUID]float64
|
||||||
tags map[string]float64
|
tags map[string]float64
|
||||||
|
eras map[string]float64
|
||||||
|
moods map[string]float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
|
// 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 {
|
if err != nil {
|
||||||
return TasteProfile{}, err
|
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{
|
p := TasteProfile{
|
||||||
artists: make(map[pgtype.UUID]float64, len(arts)),
|
artists: make(map[pgtype.UUID]float64, len(arts)),
|
||||||
tags: make(map[string]float64, len(tags)),
|
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 {
|
for _, a := range arts {
|
||||||
p.artists[a.ArtistID] = a.Weight
|
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 {
|
for _, t := range tags {
|
||||||
p.tags[t.Tag] = t.Weight
|
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
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
|
// 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
|
// the artist's taste weight, the average of its genre tags' weights, its
|
||||||
// tanh-squashed. Absent artist/tags contribute 0.
|
// decade/era weight, and the average of its mood buckets' weights, each
|
||||||
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
|
// 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)
|
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
|
||||||
|
|
||||||
var tg float64
|
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)
|
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].
|
// clampUnit constrains x to [-1, 1].
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ func uuidN(n byte) pgtype.UUID {
|
|||||||
|
|
||||||
func strPtr(s string) *string { return &s }
|
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) {
|
func TestTasteProfile_Match(t *testing.T) {
|
||||||
loved := uuidN(1)
|
loved := uuidN(1)
|
||||||
disliked := uuidN(2)
|
disliked := uuidN(2)
|
||||||
@@ -26,32 +34,76 @@ func TestTasteProfile_Match(t *testing.T) {
|
|||||||
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0},
|
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)
|
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)
|
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)
|
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
|
||||||
}
|
}
|
||||||
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
||||||
// clearly positive.
|
// 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)
|
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
|
||||||
}
|
}
|
||||||
// Output stays within [-1, 1] even with saturated inputs.
|
// Output stays within [-1, 1] even with saturated inputs.
|
||||||
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
|
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 {
|
if m < -1 || m > 1 {
|
||||||
t.Errorf("Match out of [-1,1]: %.3f", m)
|
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) {
|
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
|
||||||
var p TasteProfile // zero value: nil maps
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("load: %v", err)
|
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)
|
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)
|
t.Errorf("absent artist Match = %.3f, want 0", m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ var weightFields = map[string]weightField{
|
|||||||
get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
|
get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
|
||||||
set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v },
|
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
|
// applyWeightPatch validates and applies a partial update, returning
|
||||||
@@ -129,6 +133,12 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
|
|||||||
target = &next.EngagementNeutral
|
target = &next.EngagementNeutral
|
||||||
case "engagement_full":
|
case "engagement_full":
|
||||||
target = &next.EngagementFull
|
target = &next.EngagementFull
|
||||||
|
case "enriched_tag_scale":
|
||||||
|
target = &next.EnrichedTagScale
|
||||||
|
case "era_scale":
|
||||||
|
target = &next.EraScale
|
||||||
|
case "mood_scale":
|
||||||
|
target = &next.MoodScale
|
||||||
default:
|
default:
|
||||||
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
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_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
|
||||||
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
|
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
|
||||||
add("engagement_full", a.EngagementFull, b.EngagementFull)
|
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
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,28 +38,36 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// TasteTuning is the tunable subset of taste.Config: the engagement
|
// 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 {
|
type TasteTuning struct {
|
||||||
HalfLifeDays float64
|
HalfLifeDays float64
|
||||||
EngagementHardSkip float64
|
EngagementHardSkip float64
|
||||||
EngagementNeutral float64
|
EngagementNeutral float64
|
||||||
EngagementFull float64
|
EngagementFull float64
|
||||||
|
EnrichedTagScale float64
|
||||||
|
EraScale float64
|
||||||
|
MoodScale float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
||||||
// here from config.RecommendationConfig — YAML is bootstrap-only,
|
// here from config.RecommendationConfig — YAML is bootstrap-only,
|
||||||
// rule: config in UI). Radio is seed-directed (the user picked a
|
// rule: config in UI). Radio is seed-directed (the user picked a
|
||||||
// direction), so taste is a lighter nudge than in the daily mixes.
|
// 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 {
|
func ShippedRadioWeights() recommendation.ScoringWeights {
|
||||||
return recommendation.ScoringWeights{
|
return recommendation.ScoringWeights{
|
||||||
BaseWeight: 1.0,
|
BaseWeight: 1.0,
|
||||||
LikeBoost: 2.0,
|
LikeBoost: 2.0,
|
||||||
RecencyWeight: 1.0,
|
RecencyWeight: 1.0,
|
||||||
SkipPenalty: 1.0,
|
SkipPenalty: 1.0,
|
||||||
JitterMagnitude: 0.1,
|
JitterMagnitude: 0.1,
|
||||||
ContextWeight: 2.0,
|
ContextWeight: 2.0,
|
||||||
SimilarityWeight: 2.0,
|
SimilarityWeight: 2.0,
|
||||||
TasteWeight: 1.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.
|
// Must stay in sync with the pre-push literal in playlists/system.go.
|
||||||
func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
||||||
return recommendation.ScoringWeights{
|
return recommendation.ScoringWeights{
|
||||||
BaseWeight: 1.0,
|
BaseWeight: 1.0,
|
||||||
LikeBoost: 2.0,
|
LikeBoost: 2.0,
|
||||||
RecencyWeight: 1.0,
|
RecencyWeight: 1.0,
|
||||||
SkipPenalty: 2.0,
|
SkipPenalty: 2.0,
|
||||||
JitterMagnitude: 0.1,
|
JitterMagnitude: 0.1,
|
||||||
ContextWeight: 0.5,
|
ContextWeight: 0.5,
|
||||||
SimilarityWeight: 1.5,
|
SimilarityWeight: 1.5,
|
||||||
TasteWeight: 1.5,
|
TasteWeight: 1.5,
|
||||||
|
ContextTimeWeight: 1.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +95,9 @@ func ShippedTasteTuning() TasteTuning {
|
|||||||
EngagementHardSkip: d.Engagement.HardSkip,
|
EngagementHardSkip: d.Engagement.HardSkip,
|
||||||
EngagementNeutral: d.Engagement.NeutralCompletion,
|
EngagementNeutral: d.Engagement.NeutralCompletion,
|
||||||
EngagementFull: d.Engagement.FullCompletion,
|
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,
|
EngagementHardSkip: st.EngagementHardSkip,
|
||||||
EngagementNeutral: st.EngagementNeutral,
|
EngagementNeutral: st.EngagementNeutral,
|
||||||
EngagementFull: st.EngagementFull,
|
EngagementFull: st.EngagementFull,
|
||||||
|
EnrichedTagScale: st.EnrichedTagScale,
|
||||||
|
EraScale: st.EraScale,
|
||||||
|
MoodScale: st.MoodScale,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("seed taste tuning: %w", err)
|
return fmt.Errorf("seed taste tuning: %w", err)
|
||||||
}
|
}
|
||||||
@@ -156,6 +171,9 @@ func (s *Service) reconcile(ctx context.Context) error {
|
|||||||
EngagementHardSkip: tt.EngagementHardSkip,
|
EngagementHardSkip: tt.EngagementHardSkip,
|
||||||
EngagementNeutral: tt.EngagementNeutral,
|
EngagementNeutral: tt.EngagementNeutral,
|
||||||
EngagementFull: tt.EngagementFull,
|
EngagementFull: tt.EngagementFull,
|
||||||
|
EnrichedTagScale: tt.EnrichedTagScale,
|
||||||
|
EraScale: tt.EraScale,
|
||||||
|
MoodScale: tt.MoodScale,
|
||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
@@ -205,6 +223,9 @@ func (s *Service) TasteConfig() taste.Config {
|
|||||||
NeutralCompletion: t.EngagementNeutral,
|
NeutralCompletion: t.EngagementNeutral,
|
||||||
FullCompletion: t.EngagementFull,
|
FullCompletion: t.EngagementFull,
|
||||||
}
|
}
|
||||||
|
cfg.EnrichedTagScale = t.EnrichedTagScale
|
||||||
|
cfg.EraScale = t.EraScale
|
||||||
|
cfg.MoodScale = t.MoodScale
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +322,9 @@ func (s *Service) persistTaste(
|
|||||||
EngagementHardSkip: t.EngagementHardSkip,
|
EngagementHardSkip: t.EngagementHardSkip,
|
||||||
EngagementNeutral: t.EngagementNeutral,
|
EngagementNeutral: t.EngagementNeutral,
|
||||||
EngagementFull: t.EngagementFull,
|
EngagementFull: t.EngagementFull,
|
||||||
|
EnrichedTagScale: t.EnrichedTagScale,
|
||||||
|
EraScale: t.EraScale,
|
||||||
|
MoodScale: t.MoodScale,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return fmt.Errorf("update taste tuning: %w", err)
|
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 {
|
func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
|
||||||
return dbq.UpsertWeightProfileDefaultsParams{
|
return dbq.UpsertWeightProfileDefaultsParams{
|
||||||
Profile: profile,
|
Profile: profile,
|
||||||
BaseWeight: w.BaseWeight,
|
BaseWeight: w.BaseWeight,
|
||||||
LikeBoost: w.LikeBoost,
|
LikeBoost: w.LikeBoost,
|
||||||
RecencyWeight: w.RecencyWeight,
|
RecencyWeight: w.RecencyWeight,
|
||||||
SkipPenalty: w.SkipPenalty,
|
SkipPenalty: w.SkipPenalty,
|
||||||
JitterMagnitude: w.JitterMagnitude,
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
ContextWeight: w.ContextWeight,
|
ContextWeight: w.ContextWeight,
|
||||||
SimilarityWeight: w.SimilarityWeight,
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
TasteWeight: w.TasteWeight,
|
TasteWeight: w.TasteWeight,
|
||||||
|
ContextTimeWeight: w.ContextTimeWeight,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
|
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
|
||||||
return dbq.UpdateWeightProfileParams{
|
return dbq.UpdateWeightProfileParams{
|
||||||
Profile: profile,
|
Profile: profile,
|
||||||
BaseWeight: w.BaseWeight,
|
BaseWeight: w.BaseWeight,
|
||||||
LikeBoost: w.LikeBoost,
|
LikeBoost: w.LikeBoost,
|
||||||
RecencyWeight: w.RecencyWeight,
|
RecencyWeight: w.RecencyWeight,
|
||||||
SkipPenalty: w.SkipPenalty,
|
SkipPenalty: w.SkipPenalty,
|
||||||
JitterMagnitude: w.JitterMagnitude,
|
JitterMagnitude: w.JitterMagnitude,
|
||||||
ContextWeight: w.ContextWeight,
|
ContextWeight: w.ContextWeight,
|
||||||
SimilarityWeight: w.SimilarityWeight,
|
SimilarityWeight: w.SimilarityWeight,
|
||||||
TasteWeight: w.TasteWeight,
|
TasteWeight: w.TasteWeight,
|
||||||
|
ContextTimeWeight: w.ContextTimeWeight,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
|
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
|
||||||
return recommendation.ScoringWeights{
|
return recommendation.ScoringWeights{
|
||||||
BaseWeight: r.BaseWeight,
|
BaseWeight: r.BaseWeight,
|
||||||
LikeBoost: r.LikeBoost,
|
LikeBoost: r.LikeBoost,
|
||||||
RecencyWeight: r.RecencyWeight,
|
RecencyWeight: r.RecencyWeight,
|
||||||
SkipPenalty: r.SkipPenalty,
|
SkipPenalty: r.SkipPenalty,
|
||||||
JitterMagnitude: r.JitterMagnitude,
|
JitterMagnitude: r.JitterMagnitude,
|
||||||
ContextWeight: r.ContextWeight,
|
ContextWeight: r.ContextWeight,
|
||||||
SimilarityWeight: r.SimilarityWeight,
|
SimilarityWeight: r.SimilarityWeight,
|
||||||
TasteWeight: r.TasteWeight,
|
TasteWeight: r.TasteWeight,
|
||||||
|
ContextTimeWeight: r.ContextTimeWeight,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
func TestReset_RestoresShippedAndAudits(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
s := newService(t, pool)
|
s := newService(t, pool)
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recsettings"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/subsonic"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tracks"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/web"
|
"git.fabledsword.com/bvandeusen/minstrel/web"
|
||||||
)
|
)
|
||||||
@@ -77,6 +78,7 @@ type Server struct {
|
|||||||
BrandingCfg config.BrandingConfig
|
BrandingCfg config.BrandingConfig
|
||||||
CoverEnricher *coverart.Enricher
|
CoverEnricher *coverart.Enricher
|
||||||
CoverSettings *coverart.SettingsService
|
CoverSettings *coverart.SettingsService
|
||||||
|
TagSettings *tags.SettingsService
|
||||||
LibraryScanner *library.Scanner
|
LibraryScanner *library.Scanner
|
||||||
ScanCfg library.RunScanConfig
|
ScanCfg library.RunScanConfig
|
||||||
// Bus is the live-event bus shared with background workers (the
|
// Bus is the live-event bus shared with background workers (the
|
||||||
@@ -162,7 +164,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
s.Logger.Error("server: recsettings boot failed", "err", err)
|
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
|
// /api/admin/scan is the only admin route owned by the server package
|
||||||
// (it needs the Scanner). Register it as a single inline-middleware
|
// (it needs the Scanner). Register it as a single inline-middleware
|
||||||
// route — using r.Route("/api/admin", ...) here would create a second
|
// route — using r.Route("/api/admin", ...) here would create a second
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package tags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultTopK caps how many tags are cached per track. Enough for a rich
|
||||||
|
// facet, small enough that a heavily-tagged track can't swamp the taste
|
||||||
|
// profile when the recompute unions them in.
|
||||||
|
const defaultTopK = 8
|
||||||
|
|
||||||
|
// Source labels stamped onto tracks.tag_source. A single contributing
|
||||||
|
// provider stamps its own ID; two or more stamp "mixed". Kept in sync with
|
||||||
|
// the SetTrackTagSource query's documented whitelist.
|
||||||
|
const (
|
||||||
|
sourceNone = "none"
|
||||||
|
sourceMixed = "mixed"
|
||||||
|
)
|
||||||
|
|
||||||
|
// outcome classifies one track's enrichment for the batch tally.
|
||||||
|
type outcome int
|
||||||
|
|
||||||
|
const (
|
||||||
|
outcomeEnriched outcome = iota // wrote ≥1 tag
|
||||||
|
outcomeNone // every provider had nothing → settled 'none'
|
||||||
|
outcomeLeftNull // a transient failure → left NULL for retry
|
||||||
|
)
|
||||||
|
|
||||||
|
// Enricher drains tracks needing tags, fetches from the enabled provider
|
||||||
|
// chain, MERGES the results (union across providers, unlike coverart's
|
||||||
|
// first-success-wins for a single image), and caches the top-K into
|
||||||
|
// track_tags. Owns no pool; receives one via the constructor so the boot
|
||||||
|
// backfill, post-scan trigger, and admin endpoints share state.
|
||||||
|
type Enricher struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
logger *slog.Logger
|
||||||
|
settings *SettingsService
|
||||||
|
topK int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEnricher constructs an Enricher.
|
||||||
|
func NewEnricher(pool *pgxpool.Pool, logger *slog.Logger, settings *SettingsService) *Enricher {
|
||||||
|
return &Enricher{pool: pool, logger: logger, settings: settings, topK: defaultTopK}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrichTrack runs the chain for one track and persists the merged result.
|
||||||
|
//
|
||||||
|
// Every enabled TrackTagProvider is queried and its tags unioned (max
|
||||||
|
// weight wins on overlap). If any tags surface they're written with the
|
||||||
|
// source stamped by which providers contributed (musicbrainz / lastfm /
|
||||||
|
// mixed). If every provider cleanly reports ErrNotFound (or none are
|
||||||
|
// enabled) the row settles to 'none'. If a provider fails transiently and
|
||||||
|
// no tags surfaced, the row is left NULL for a next-pass retry.
|
||||||
|
func (e *Enricher) EnrichTrack(ctx context.Context, trackID pgtype.UUID, ref TrackRef) (outcome, error) {
|
||||||
|
merged := map[string]float64{}
|
||||||
|
contributors := map[string]bool{}
|
||||||
|
anyTransient := false
|
||||||
|
|
||||||
|
for _, provider := range e.settings.EnabledTrackTagProviders() {
|
||||||
|
tags, perr := provider.FetchTrackTags(ctx, ref)
|
||||||
|
switch {
|
||||||
|
case perr == nil:
|
||||||
|
for _, t := range tags {
|
||||||
|
if t.Weight > merged[t.Name] {
|
||||||
|
merged[t.Name] = t.Weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(tags) > 0 {
|
||||||
|
contributors[provider.ID()] = true
|
||||||
|
}
|
||||||
|
case errors.Is(perr, ErrNotFound):
|
||||||
|
// Clean "no data from this source" — try the next provider.
|
||||||
|
default:
|
||||||
|
anyTransient = true
|
||||||
|
e.logger.Warn("tags: provider fetch failed; continuing",
|
||||||
|
"track_id", uuidString(trackID), "provider", provider.ID(), "err", perr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(merged) > 0 {
|
||||||
|
top := topKByWeight(merged, e.topK)
|
||||||
|
source := sourceLabel(contributorIDs(contributors))
|
||||||
|
if err := e.writeTags(ctx, trackID, top, source, e.settings.CurrentVersion()); err != nil {
|
||||||
|
return outcomeLeftNull, err
|
||||||
|
}
|
||||||
|
return outcomeEnriched, nil
|
||||||
|
}
|
||||||
|
if anyTransient {
|
||||||
|
// Nothing landed but a source may recover — leave NULL for retry.
|
||||||
|
return outcomeLeftNull, nil
|
||||||
|
}
|
||||||
|
// Every provider (if any) had nothing: settle 'none' so we don't
|
||||||
|
// re-process until the version bumps (a provider enabled/added).
|
||||||
|
if err := e.writeTags(ctx, trackID, nil, sourceNone, e.settings.CurrentVersion()); err != nil {
|
||||||
|
return outcomeNone, err
|
||||||
|
}
|
||||||
|
return outcomeNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTags atomically replaces a track's cached tags and stamps the source
|
||||||
|
// + version. tags may be empty (the 'none' settle path), which just clears
|
||||||
|
// any prior tags and records the outcome.
|
||||||
|
func (e *Enricher) writeTags(ctx context.Context, trackID pgtype.UUID, tags []Tag, source string, version int32) error {
|
||||||
|
tx, err := e.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
q := dbq.New(tx)
|
||||||
|
if err := q.DeleteTrackTags(ctx, trackID); err != nil {
|
||||||
|
return fmt.Errorf("delete track tags: %w", err)
|
||||||
|
}
|
||||||
|
for _, t := range tags {
|
||||||
|
if err := q.InsertTrackTag(ctx, dbq.InsertTrackTagParams{
|
||||||
|
TrackID: trackID, Tag: t.Name, Weight: t.Weight,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("insert track tag: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
src := source
|
||||||
|
if err := q.SetTrackTagSource(ctx, dbq.SetTrackTagSourceParams{
|
||||||
|
ID: trackID, TagSource: &src, TagSourcesVersion: version,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("set track tag source: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnrichTrackBatch drains up to limit tracks needing tags and enriches each
|
||||||
|
// serially. limit: 0 = disabled; >0 = bounded; <0 = unbounded (remote
|
||||||
|
// providers self-throttle via their httpClient MinInterval). Returns the
|
||||||
|
// processed/succeeded/failed tally for the scan-run record.
|
||||||
|
func (e *Enricher) EnrichTrackBatch(ctx context.Context, limit int,
|
||||||
|
progressCb func(processed, succeeded, failed int)) (processed, succeeded, failed int, err error) {
|
||||||
|
if limit == 0 {
|
||||||
|
return 0, 0, 0, nil
|
||||||
|
}
|
||||||
|
queryLimit := int32(limit)
|
||||||
|
if limit < 0 {
|
||||||
|
queryLimit = 1<<31 - 1
|
||||||
|
}
|
||||||
|
q := dbq.New(e.pool)
|
||||||
|
rows, qerr := q.ListTracksMissingTags(ctx, dbq.ListTracksMissingTagsParams{
|
||||||
|
TagSourcesVersion: e.settings.CurrentVersion(),
|
||||||
|
Limit: queryLimit,
|
||||||
|
})
|
||||||
|
if qerr != nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("list tracks missing tags: %w", qerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var enriched, settledNone, leftNull, errored int
|
||||||
|
for _, r := range rows {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
e.logBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
|
||||||
|
return processed, enriched, settledNone + leftNull + errored, ctx.Err()
|
||||||
|
}
|
||||||
|
processed++
|
||||||
|
ref := TrackRef{ArtistName: r.ArtistName, Title: r.Title}
|
||||||
|
if r.Mbid != nil {
|
||||||
|
ref.MBID = *r.Mbid
|
||||||
|
}
|
||||||
|
if r.ArtistMbid != nil {
|
||||||
|
ref.ArtistMBID = *r.ArtistMbid
|
||||||
|
}
|
||||||
|
oc, eerr := e.EnrichTrack(ctx, r.ID, ref)
|
||||||
|
if eerr != nil {
|
||||||
|
e.logger.Warn("tags: batch entry failed", "track_id", uuidString(r.ID), "err", eerr)
|
||||||
|
errored++
|
||||||
|
} else {
|
||||||
|
switch oc {
|
||||||
|
case outcomeEnriched:
|
||||||
|
enriched++
|
||||||
|
case outcomeNone:
|
||||||
|
settledNone++
|
||||||
|
case outcomeLeftNull:
|
||||||
|
leftNull++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if progressCb != nil {
|
||||||
|
progressCb(processed, enriched, settledNone+leftNull+errored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.logBatchSummary(len(rows), processed, enriched, settledNone, leftNull, errored)
|
||||||
|
return processed, enriched, settledNone + leftNull + errored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logBatchSummary emits one Info line with the category breakdown — the
|
||||||
|
// enriched/settled/left-null split is the operator's diagnostic for a
|
||||||
|
// "0 enriched" symptom the collapsed tally can't explain.
|
||||||
|
func (e *Enricher) logBatchSummary(eligible, processed, enriched, settledNone, leftNull, errored int) {
|
||||||
|
e.logger.Info("tags: enrichment batch complete",
|
||||||
|
"eligible", eligible,
|
||||||
|
"processed", processed,
|
||||||
|
"enriched", enriched,
|
||||||
|
"settled_none", settledNone,
|
||||||
|
"left_null", leftNull,
|
||||||
|
"errored", errored)
|
||||||
|
}
|
||||||
|
|
||||||
|
// topKByWeight returns the highest-weighted tags, tie-broken by name for
|
||||||
|
// determinism, capped at k (k<=0 means no cap).
|
||||||
|
func topKByWeight(merged map[string]float64, k int) []Tag {
|
||||||
|
tags := make([]Tag, 0, len(merged))
|
||||||
|
for name, w := range merged {
|
||||||
|
tags = append(tags, Tag{Name: name, Weight: w})
|
||||||
|
}
|
||||||
|
sort.Slice(tags, func(i, j int) bool {
|
||||||
|
if tags[i].Weight != tags[j].Weight {
|
||||||
|
return tags[i].Weight > tags[j].Weight
|
||||||
|
}
|
||||||
|
return tags[i].Name < tags[j].Name
|
||||||
|
})
|
||||||
|
if k > 0 && len(tags) > k {
|
||||||
|
tags = tags[:k]
|
||||||
|
}
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
// contributorIDs returns the sorted set of provider IDs that returned tags.
|
||||||
|
func contributorIDs(set map[string]bool) []string {
|
||||||
|
ids := make([]string, 0, len(set))
|
||||||
|
for id := range set {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
sort.Strings(ids)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceLabel maps the contributing providers onto the tag_source value:
|
||||||
|
// none / a single provider's own ID / "mixed" for two or more.
|
||||||
|
func sourceLabel(ids []string) string {
|
||||||
|
switch len(ids) {
|
||||||
|
case 0:
|
||||||
|
return sourceNone
|
||||||
|
case 1:
|
||||||
|
return ids[0]
|
||||||
|
default:
|
||||||
|
return sourceMixed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// uuidString renders a pgtype.UUID for logs.
|
||||||
|
func uuidString(u pgtype.UUID) string {
|
||||||
|
if !u.Valid {
|
||||||
|
return "<nil>"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%x-%x-%x-%x-%x",
|
||||||
|
u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package tags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpClientOptions configures a httpClient. A trimmed sibling of the
|
||||||
|
// coverart HTTP helper — JSON-only (tag APIs return no binary), plus a
|
||||||
|
// User-Agent field because MusicBrainz rejects requests without one.
|
||||||
|
// Kept local to this package so tag enrichment never depends on
|
||||||
|
// coverart's internals.
|
||||||
|
type httpClientOptions struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
// Name prefixes wrapped error messages (e.g. "tags lastfm: ...").
|
||||||
|
Name string
|
||||||
|
// UserAgent is sent on every request. MusicBrainz mandates a
|
||||||
|
// descriptive one; empty omits the header.
|
||||||
|
UserAgent string
|
||||||
|
// MinInterval throttles requests; 0 disables rate limiting.
|
||||||
|
MinInterval time.Duration
|
||||||
|
// MaxRetries is the number of retry attempts on 429 / 5xx.
|
||||||
|
MaxRetries int
|
||||||
|
// BaseBackoff is the base unit for exponential backoff (2^attempt × base ± 20%).
|
||||||
|
BaseBackoff time.Duration
|
||||||
|
// TreatAuthAsTransient maps 401/403 to ErrTransient (keyed providers
|
||||||
|
// want this so a bad/expired key retries rather than settling 'none').
|
||||||
|
TreatAuthAsTransient bool
|
||||||
|
// JSONBodyLimit caps the response body read.
|
||||||
|
JSONBodyLimit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpClient performs rate-limited, retrying JSON GETs. Each provider
|
||||||
|
// owns one, configured with provider-specific intervals + auth semantics.
|
||||||
|
type httpClient struct {
|
||||||
|
opts httpClientOptions
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
lastCall time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPClient(opts httpClientOptions) *httpClient {
|
||||||
|
if opts.HTTPClient == nil {
|
||||||
|
opts.HTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
if opts.MaxRetries == 0 {
|
||||||
|
opts.MaxRetries = 3
|
||||||
|
}
|
||||||
|
if opts.BaseBackoff == 0 {
|
||||||
|
opts.BaseBackoff = 250 * time.Millisecond
|
||||||
|
}
|
||||||
|
if opts.JSONBodyLimit == 0 {
|
||||||
|
opts.JSONBodyLimit = 1 * 1024 * 1024
|
||||||
|
}
|
||||||
|
return &httpClient{opts: opts}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJSON sends GET fullURL and decodes the 200 body into out (capped at
|
||||||
|
// JSONBodyLimit). Retries 429/5xx with exponential backoff up to
|
||||||
|
// MaxRetries. 404 → ErrNotFound; other non-2xx → wrapped ErrTransient.
|
||||||
|
func (c *httpClient) getJSON(ctx context.Context, fullURL string, out any) error {
|
||||||
|
for attempt := 0; attempt <= c.opts.MaxRetries; attempt++ {
|
||||||
|
if err := c.rateLimitWait(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("tags %s: build request: %w", c.opts.Name, err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if c.opts.UserAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", c.opts.UserAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := c.opts.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
if attempt < c.opts.MaxRetries {
|
||||||
|
if waitErr := c.backoff(ctx, attempt); waitErr != nil {
|
||||||
|
return waitErr
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %v", ErrTransient, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retry, ferr := c.handleStatus(ctx, resp, attempt)
|
||||||
|
if retry {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ferr != nil {
|
||||||
|
return ferr
|
||||||
|
}
|
||||||
|
|
||||||
|
body, rerr := io.ReadAll(io.LimitReader(resp.Body, c.opts.JSONBodyLimit))
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if rerr != nil {
|
||||||
|
return fmt.Errorf("%w: read body: %v", ErrTransient, rerr)
|
||||||
|
}
|
||||||
|
if uerr := json.Unmarshal(body, out); uerr != nil {
|
||||||
|
return fmt.Errorf("%w: decode body: %v", ErrTransient, uerr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: exhausted retries", ErrTransient)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleStatus classifies a response. Returns retry=true to loop again
|
||||||
|
// (after backing off), or a terminal error, or (false, nil) to proceed to
|
||||||
|
// body read. Closes the body on every path except the proceed case.
|
||||||
|
func (c *httpClient) handleStatus(ctx context.Context, resp *http.Response, attempt int) (retry bool, err error) {
|
||||||
|
switch {
|
||||||
|
case resp.StatusCode == http.StatusNotFound:
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return false, ErrNotFound
|
||||||
|
case c.opts.TreatAuthAsTransient &&
|
||||||
|
(resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden):
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return false, fmt.Errorf("%w: status %d (auth)", ErrTransient, resp.StatusCode)
|
||||||
|
case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if attempt < c.opts.MaxRetries {
|
||||||
|
if waitErr := c.backoff(ctx, attempt); waitErr != nil {
|
||||||
|
return false, waitErr
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
|
||||||
|
case resp.StatusCode != http.StatusOK:
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return false, fmt.Errorf("%w: status %d", ErrTransient, resp.StatusCode)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimitWait blocks until MinInterval has elapsed since the last call,
|
||||||
|
// then claims the slot. Disabled when MinInterval is 0.
|
||||||
|
func (c *httpClient) rateLimitWait(ctx context.Context) error {
|
||||||
|
if c.opts.MinInterval <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
wait := time.Until(c.lastCall.Add(c.opts.MinInterval))
|
||||||
|
if wait > 0 {
|
||||||
|
timer := time.NewTimer(wait)
|
||||||
|
c.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
timer.Stop()
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
}
|
||||||
|
c.lastCall = time.Now()
|
||||||
|
c.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// backoff sleeps for 2^attempt × BaseBackoff ± 20% jitter, or until ctx is done.
|
||||||
|
func (c *httpClient) backoff(ctx context.Context, attempt int) error {
|
||||||
|
base := time.Duration(1<<attempt) * c.opts.BaseBackoff
|
||||||
|
jitterRange := int64(base) / 5
|
||||||
|
if jitterRange <= 0 {
|
||||||
|
jitterRange = 1
|
||||||
|
}
|
||||||
|
jitter := time.Duration(rand.Int63n(jitterRange))
|
||||||
|
if rand.Intn(2) == 0 {
|
||||||
|
jitter = -jitter
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(base + jitter)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Package tags enriches the taste profile's tag facet beyond raw ID3
|
||||||
|
// genre (milestone #160, task #1490). It fetches folksonomy style/mood
|
||||||
|
// tags for tracks from a chain of pluggable providers (MusicBrainz,
|
||||||
|
// Last.fm, …), caches them in track_tags, and the taste recompute unions
|
||||||
|
// them alongside tracks.genre.
|
||||||
|
//
|
||||||
|
// The provider model mirrors internal/coverart deliberately: a source is
|
||||||
|
// added by implementing TrackTagProvider and calling Register() in the
|
||||||
|
// provider file's init() — no enricher, settings, or schema change. The
|
||||||
|
// DB-backed SettingsService then auto-upserts a settings row for it, and
|
||||||
|
// the boot-time provider-hash bump makes previously-settled rows eligible
|
||||||
|
// for a retry through the widened chain.
|
||||||
|
package tags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tag is one folksonomy tag with a normalized strength in [0,1]. Each
|
||||||
|
// provider normalizes its own upstream weighting (Last.fm's 0-100 count,
|
||||||
|
// MusicBrainz's vote count) into this range so the enricher can merge
|
||||||
|
// across providers on a common scale.
|
||||||
|
type Tag struct {
|
||||||
|
Name string
|
||||||
|
Weight float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackRef is the lookup key passed to a provider. MBID is the track's
|
||||||
|
// recording MBID (preferred by MBID-keyed providers); ArtistName + Title
|
||||||
|
// are the always-populated fallback for name-based providers (Last.fm);
|
||||||
|
// ArtistMBID (nullable) enables MusicBrainz's artist-tag fallback (#1519)
|
||||||
|
// when the recording itself is untagged or MBID-less.
|
||||||
|
type TrackRef struct {
|
||||||
|
MBID string
|
||||||
|
ArtistName string
|
||||||
|
Title string
|
||||||
|
ArtistMBID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider is the base shape every tag source implements. Providers
|
||||||
|
// register at init() via Register(). The SettingsService reads the
|
||||||
|
// per-provider row (enabled, api_key) and calls Configure().
|
||||||
|
type Provider interface {
|
||||||
|
// ID is a stable lowercase token persisted in tracks.tag_source and
|
||||||
|
// keyed in tag_provider_settings.provider_id, and shown in the admin UI.
|
||||||
|
ID() string
|
||||||
|
|
||||||
|
// DisplayName is the human-readable label for the admin Settings card.
|
||||||
|
DisplayName() string
|
||||||
|
|
||||||
|
// RequiresAPIKey reports whether the provider needs a key to function.
|
||||||
|
// The admin UI shows/hides the key field on this; it does NOT gate the
|
||||||
|
// enabled toggle (rule #26 — providers with usable defaults ship working).
|
||||||
|
RequiresAPIKey() bool
|
||||||
|
|
||||||
|
// DefaultEnabled is whether a freshly-installed Minstrel ships with
|
||||||
|
// this provider on. MusicBrainz (keyless) returns true; Last.fm
|
||||||
|
// (keyed) returns false — opt-in once a key is supplied.
|
||||||
|
DefaultEnabled() bool
|
||||||
|
|
||||||
|
// Configure applies the operator's settings. Called at boot and on
|
||||||
|
// every admin PATCH; must be idempotent and goroutine-safe (the
|
||||||
|
// enricher may read concurrently).
|
||||||
|
Configure(ProviderSettings) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderSettings is the runtime config a provider receives on Configure.
|
||||||
|
type ProviderSettings struct {
|
||||||
|
Enabled bool
|
||||||
|
APIKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackTagProvider is the tag-fetch capability. Split from Provider so a
|
||||||
|
// future source with a different capability (e.g. artist-level tags) can
|
||||||
|
// be added without widening this one.
|
||||||
|
type TrackTagProvider interface {
|
||||||
|
Provider
|
||||||
|
// FetchTrackTags returns the folksonomy tags for the track, or
|
||||||
|
// ErrNotFound (terminal — nothing upstream) / ErrTransient (retry).
|
||||||
|
// A disabled provider, or one missing a required key, returns
|
||||||
|
// ErrNotFound so the chain simply skips it.
|
||||||
|
FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestableProvider is an opt-in capability for the admin Test-Connection
|
||||||
|
// button: answer "is my config working?" without a full enrichment cycle.
|
||||||
|
type TestableProvider interface {
|
||||||
|
Provider
|
||||||
|
TestConnection(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNotFound: the provider confirmed it has no tags for this track — a
|
||||||
|
// terminal-this-pass result. The enricher moves to the next provider; if
|
||||||
|
// every provider returns ErrNotFound the row settles to tag_source='none'.
|
||||||
|
// Aliased to apierror.ErrNotFound for consistency with the rest of the app.
|
||||||
|
var ErrNotFound = apierror.ErrNotFound
|
||||||
|
|
||||||
|
// ErrTransient: a temporary failure (5xx, network, timeout, auth). The
|
||||||
|
// enricher leaves the row's tag_source NULL so the next pass retries.
|
||||||
|
var ErrTransient = errors.New("tags: transient error")
|
||||||
|
|
||||||
|
// ErrProviderNotFound is returned when a named provider is not registered.
|
||||||
|
var ErrProviderNotFound = errors.New("tags: provider not registered")
|
||||||
|
|
||||||
|
// ErrNotTestable is returned when a provider doesn't implement
|
||||||
|
// TestableProvider.
|
||||||
|
var ErrNotTestable = errors.New("tags: provider does not support test connection")
|
||||||
|
|
||||||
|
// registry holds all compiled-in providers. Providers register at init()
|
||||||
|
// via Register(); the enricher iterates this list filtered by settings.
|
||||||
|
var registry = struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
providers []Provider
|
||||||
|
}{}
|
||||||
|
|
||||||
|
// Register adds a provider. Must be called from a provider file's init().
|
||||||
|
// Duplicate IDs panic — providers are compiled-in, so it's a build bug.
|
||||||
|
func Register(p Provider) {
|
||||||
|
registry.mu.Lock()
|
||||||
|
defer registry.mu.Unlock()
|
||||||
|
for _, existing := range registry.providers {
|
||||||
|
if existing.ID() == p.ID() {
|
||||||
|
panic(fmt.Sprintf("tags: duplicate provider ID %q", p.ID()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registry.providers = append(registry.providers, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AllProviders returns a snapshot in registration order. Do not mutate.
|
||||||
|
func AllProviders() []Provider {
|
||||||
|
registry.mu.Lock()
|
||||||
|
defer registry.mu.Unlock()
|
||||||
|
out := make([]Provider, len(registry.providers))
|
||||||
|
copy(out, registry.providers)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProviderByID looks up a registered provider. Returns ErrProviderNotFound
|
||||||
|
// if none matches.
|
||||||
|
func ProviderByID(id string) (Provider, error) {
|
||||||
|
registry.mu.Lock()
|
||||||
|
defer registry.mu.Unlock()
|
||||||
|
for _, p := range registry.providers {
|
||||||
|
if p.ID() == id {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrProviderNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetRegistryForTests clears the registry. Test-only.
|
||||||
|
func ResetRegistryForTests() {
|
||||||
|
registry.mu.Lock()
|
||||||
|
defer registry.mu.Unlock()
|
||||||
|
registry.providers = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package tags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// lastfmBaseURL is a var (not const) so tests can override it.
|
||||||
|
var lastfmBaseURL = "https://ws.audioscrobbler.com/2.0/"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// lastfmMinPeriod throttles to 4 req/s under the 5/s per-key ceiling.
|
||||||
|
lastfmMinPeriod = 250 * time.Millisecond
|
||||||
|
// lastfmTagMaxCount is Last.fm's top-tag count scale (0-100 popularity).
|
||||||
|
lastfmTagMaxCount = 100.0
|
||||||
|
)
|
||||||
|
|
||||||
|
// lastfmTransientErrors are Last.fm JSON-level error codes that mean
|
||||||
|
// "try again" rather than "no data": 8 operation failed, 11/16 service
|
||||||
|
// temporarily unavailable, 29 rate limit. Everything else non-zero
|
||||||
|
// (6 not found, 10 invalid key, …) is treated as ErrNotFound so the
|
||||||
|
// chain simply skips this provider for the track.
|
||||||
|
var lastfmTransientErrors = map[int]bool{8: true, 11: true, 16: true, 29: true}
|
||||||
|
|
||||||
|
// lastfmProvider fetches track-level folksonomy tags via Last.fm's
|
||||||
|
// track.getTopTags. Requires an operator-supplied API key; stays inert
|
||||||
|
// (ErrNotFound on every call) when disabled or unkeyed. Name-based, so it
|
||||||
|
// covers the many local tracks that lack a recording MBID.
|
||||||
|
type lastfmProvider struct {
|
||||||
|
enabled atomic.Bool
|
||||||
|
apiKey atomic.Pointer[string]
|
||||||
|
client *httpClient
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register(&lastfmProvider{
|
||||||
|
client: newHTTPClient(httpClientOptions{
|
||||||
|
Name: "lastfm",
|
||||||
|
HTTPClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
MinInterval: lastfmMinPeriod,
|
||||||
|
MaxRetries: 3,
|
||||||
|
TreatAuthAsTransient: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *lastfmProvider) ID() string { return "lastfm" }
|
||||||
|
func (p *lastfmProvider) DisplayName() string { return "Last.fm" }
|
||||||
|
func (p *lastfmProvider) RequiresAPIKey() bool { return true }
|
||||||
|
func (p *lastfmProvider) DefaultEnabled() bool { return false }
|
||||||
|
|
||||||
|
func (p *lastfmProvider) Configure(s ProviderSettings) error {
|
||||||
|
p.enabled.Store(s.Enabled)
|
||||||
|
key := s.APIKey
|
||||||
|
p.apiKey.Store(&key)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *lastfmProvider) currentKey() string {
|
||||||
|
if k := p.apiKey.Load(); k != nil {
|
||||||
|
return *k
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastfmTopTags models the subset of track.getTopTags we read. Last.fm
|
||||||
|
// returns `error` on failure responses instead of the toptags block.
|
||||||
|
type lastfmTopTags struct {
|
||||||
|
TopTags struct {
|
||||||
|
Tag []lastfmTag `json:"tag"`
|
||||||
|
} `json:"toptags"`
|
||||||
|
Error int `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type lastfmTag struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchTrackTags looks up the track's top tags by artist + title (with the
|
||||||
|
// recording MBID as an extra hint when present). Returns ErrNotFound when
|
||||||
|
// disabled, unkeyed, missing artist/title, or when Last.fm has no tags.
|
||||||
|
func (p *lastfmProvider) FetchTrackTags(ctx context.Context, ref TrackRef) ([]Tag, error) {
|
||||||
|
if !p.enabled.Load() || p.currentKey() == "" {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if ref.ArtistName == "" || ref.Title == "" {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
q := url.Values{
|
||||||
|
"method": {"track.gettoptags"},
|
||||||
|
"api_key": {p.currentKey()},
|
||||||
|
"format": {"json"},
|
||||||
|
"artist": {ref.ArtistName},
|
||||||
|
"track": {ref.Title},
|
||||||
|
"autocorrect": {"1"},
|
||||||
|
}
|
||||||
|
if ref.MBID != "" {
|
||||||
|
q.Set("mbid", ref.MBID)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp lastfmTopTags
|
||||||
|
if err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.Error != 0 {
|
||||||
|
if lastfmTransientErrors[resp.Error] {
|
||||||
|
return nil, ErrTransient
|
||||||
|
}
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
out := normalizeLastfmTags(resp.TopTags.Tag)
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConnection verifies the key against a well-known track.
|
||||||
|
func (p *lastfmProvider) TestConnection(ctx context.Context) error {
|
||||||
|
if p.currentKey() == "" {
|
||||||
|
return errors.New("tags: lastfm api key not set")
|
||||||
|
}
|
||||||
|
q := url.Values{
|
||||||
|
"method": {"track.gettoptags"},
|
||||||
|
"api_key": {p.currentKey()},
|
||||||
|
"format": {"json"},
|
||||||
|
"artist": {"Radiohead"},
|
||||||
|
"track": {"Creep"},
|
||||||
|
}
|
||||||
|
var resp lastfmTopTags
|
||||||
|
err := p.client.getJSON(ctx, lastfmBaseURL+"?"+q.Encode(), &resp)
|
||||||
|
if err != nil && !errors.Is(err, ErrNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.Error == 10 { // invalid API key
|
||||||
|
return errors.New("tags: lastfm rejected the api key")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeLastfmTags scales the 0-100 popularity counts into [0,1] and
|
||||||
|
// drops zero-count / unnamed tags.
|
||||||
|
func normalizeLastfmTags(raw []lastfmTag) []Tag {
|
||||||
|
out := make([]Tag, 0, len(raw))
|
||||||
|
for _, t := range raw {
|
||||||
|
if t.Count <= 0 || t.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w := float64(t.Count) / lastfmTagMaxCount
|
||||||
|
if w > 1 {
|
||||||
|
w = 1
|
||||||
|
}
|
||||||
|
out = append(out, Tag{Name: t.Name, Weight: w})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time capability checks.
|
||||||
|
var (
|
||||||
|
_ TrackTagProvider = (*lastfmProvider)(nil)
|
||||||
|
_ TestableProvider = (*lastfmProvider)(nil)
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package tags
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Worker periodically drains tracks needing tag enrichment. Unlike cover
|
||||||
|
// art (threaded through the file-scan chain because it needs track file
|
||||||
|
// paths), tag enrichment only needs data already in the DB — recording
|
||||||
|
// MBID / artist / title — so it runs as a standalone background worker,
|
||||||
|
// mirroring the ListenBrainz similarity worker. Rate limiting lives in the
|
||||||
|
// providers' httpClients, so a tick just drains a bounded batch and the
|
||||||
|
// external APIs pace themselves.
|
||||||
|
type Worker struct {
|
||||||
|
enricher *Enricher
|
||||||
|
logger *slog.Logger
|
||||||
|
tick time.Duration
|
||||||
|
batch int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorker constructs a worker with production defaults: an initial drain
|
||||||
|
// shortly after boot, then every 30 minutes, up to 200 tracks per tick.
|
||||||
|
// MusicBrainz's 1 req/s ceiling is the real throttle, so the batch size
|
||||||
|
// mainly bounds how long one tick runs, not the request rate.
|
||||||
|
func NewWorker(enricher *Enricher, logger *slog.Logger) *Worker {
|
||||||
|
return &Worker{
|
||||||
|
enricher: enricher,
|
||||||
|
logger: logger,
|
||||||
|
tick: 30 * time.Minute,
|
||||||
|
batch: 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run blocks until ctx is cancelled: an initial drain, then every w.tick.
|
||||||
|
func (w *Worker) Run(ctx context.Context) {
|
||||||
|
w.tickOnce(ctx)
|
||||||
|
t := time.NewTicker(w.tick)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
w.tickOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tickOnce drains one bounded batch. EnrichTrackBatch already logs a
|
||||||
|
// category breakdown, so this only surfaces a fatal batch error.
|
||||||
|
func (w *Worker) tickOnce(ctx context.Context) {
|
||||||
|
if _, _, _, err := w.enricher.EnrichTrackBatch(ctx, w.batch, nil); err != nil {
|
||||||
|
if ctx.Err() == nil {
|
||||||
|
w.logger.Error("tags: enrichment tick failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
-28
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
|
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
|
||||||
@@ -36,6 +37,31 @@ type Config struct {
|
|||||||
TrackLikeBonus float64
|
TrackLikeBonus float64
|
||||||
TagLikeBonus 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
|
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
|
||||||
// go. Aggregation already protects an artist the user likes (one skip
|
// go. Aggregation already protects an artist the user likes (one skip
|
||||||
// nets out against many good plays); the floor additionally bounds the
|
// nets out against many good plays); the floor additionally bounds the
|
||||||
@@ -48,29 +74,42 @@ type Config struct {
|
|||||||
WeightEpsilon float64
|
WeightEpsilon float64
|
||||||
|
|
||||||
// Size caps (guard rails for pathological libraries). Persist the top
|
// 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
|
MaxArtists int
|
||||||
MaxNegArtists int
|
MaxNegArtists int
|
||||||
MaxTags int
|
MaxTags int
|
||||||
MaxNegTags int
|
MaxNegTags int
|
||||||
|
MaxEras int
|
||||||
|
MaxNegEras int
|
||||||
|
MaxMoods int
|
||||||
|
MaxNegMoods int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultConfig returns the tuned starting configuration.
|
// DefaultConfig returns the tuned starting configuration.
|
||||||
func DefaultConfig() Config {
|
func DefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Engagement: DefaultEngagementParams(),
|
Engagement: DefaultEngagementParams(),
|
||||||
HalfLifeDays: 75,
|
HalfLifeDays: 75,
|
||||||
WindowDays: 270,
|
WindowDays: 270,
|
||||||
ArtistLikeBonus: 3.0,
|
ArtistLikeBonus: 3.0,
|
||||||
TrackLikeBonus: 1.0,
|
TrackLikeBonus: 1.0,
|
||||||
TagLikeBonus: 0.5,
|
TagLikeBonus: 0.5,
|
||||||
ArtistFloor: -3.0,
|
EnrichedTagScale: 0.5,
|
||||||
TagFloor: -3.0,
|
EraScale: 0.5,
|
||||||
WeightEpsilon: 0.05,
|
MoodScale: 0.5,
|
||||||
MaxArtists: 500,
|
ArtistFloor: -3.0,
|
||||||
MaxNegArtists: 150,
|
TagFloor: -3.0,
|
||||||
MaxTags: 200,
|
WeightEpsilon: 0.05,
|
||||||
MaxNegTags: 60,
|
MaxArtists: 500,
|
||||||
|
MaxNegArtists: 150,
|
||||||
|
MaxTags: 200,
|
||||||
|
MaxNegTags: 60,
|
||||||
|
MaxEras: 30,
|
||||||
|
MaxNegEras: 15,
|
||||||
|
MaxMoods: 20,
|
||||||
|
MaxNegMoods: 10,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,72 +129,168 @@ func BuildTasteProfile(
|
|||||||
) error {
|
) error {
|
||||||
q := dbq.New(pool)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
applyFloor(artistW, cfg.ArtistFloor)
|
applyFloor(artistW, cfg.ArtistFloor)
|
||||||
applyFloor(tagW, cfg.TagFloor)
|
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)
|
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
|
||||||
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags,
|
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
|
||||||
func(a, b string) bool { return a < b })
|
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
|
return err
|
||||||
}
|
}
|
||||||
logger.Debug("taste: profile rebuilt",
|
logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID),
|
||||||
"user_id", uuidString(userID), "artists", len(artists), "tags", len(tags))
|
"artists", len(artists), "tags", len(tags), "eras", len(eras), "moods", len(moods))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// accumulate sums decayed play engagement and like bonuses into artist/tag
|
// accumulate sums decayed play engagement and like bonuses into
|
||||||
// weight maps.
|
// artist/tag/era/mood weight maps.
|
||||||
func accumulate(
|
func accumulate(
|
||||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
|
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{
|
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Column2: cfg.WindowDays,
|
Column2: cfg.WindowDays,
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
artistW := make(map[pgtype.UUID]float64)
|
||||||
tagW := make(map[string]float64)
|
tagW := make(map[string]float64)
|
||||||
|
eraW := make(map[string]float64)
|
||||||
|
moodW := make(map[string]float64)
|
||||||
for _, p := range plays {
|
for _, p := range plays {
|
||||||
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
|
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
|
||||||
artistW[p.ArtistID] += e
|
artistW[p.ArtistID] += e
|
||||||
for _, tag := range splitGenres(p.Genre) {
|
for _, tag := range splitGenres(p.Genre) {
|
||||||
tagW[tag] += e
|
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)
|
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
|
||||||
if err != nil {
|
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 {
|
for _, lt := range likedTracks {
|
||||||
artistW[lt.ArtistID] += cfg.TrackLikeBonus
|
artistW[lt.ArtistID] += cfg.TrackLikeBonus
|
||||||
for _, tag := range splitGenres(lt.Genre) {
|
for _, tag := range splitGenres(lt.Genre) {
|
||||||
tagW[tag] += cfg.TagLikeBonus
|
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)
|
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
|
||||||
if err != nil {
|
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 {
|
for _, aid := range likedArtists {
|
||||||
artistW[aid] += cfg.ArtistLikeBonus
|
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.
|
// persist atomic-replaces the user's profile rows inside one transaction.
|
||||||
func persist(
|
func persist(
|
||||||
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
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 {
|
) error {
|
||||||
tx, err := pool.Begin(ctx)
|
tx, err := pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -184,6 +319,26 @@ func persist(
|
|||||||
return fmt.Errorf("taste: insert tag: %w", err)
|
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 {
|
if err := tx.Commit(ctx); err != nil {
|
||||||
return fmt.Errorf("taste: commit: %w", err)
|
return fmt.Errorf("taste: commit: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,39 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
)
|
)
|
||||||
|
|
||||||
func strLess(a, b string) bool { return a < b }
|
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) {
|
func TestRankWeighted_DropsEpsilonAndSortsDesc(t *testing.T) {
|
||||||
m := map[string]float64{"a": 3.0, "b": 1.0, "tiny": 0.01, "d": -2.0}
|
m := map[string]float64{"a": 3.0, "b": 1.0, "tiny": 0.01, "d": -2.0}
|
||||||
got := rankWeighted(m, 0.05, 10, 10, strLess)
|
got := rankWeighted(m, 0.05, 10, 10, strLess)
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
getTagProviders,
|
||||||
|
updateTagProvider,
|
||||||
|
testTagProvider,
|
||||||
|
type TagProvider,
|
||||||
|
type TagProvidersResponse
|
||||||
|
} from './admin';
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
describe('admin tag-sources API', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it('getTagProviders GETs the correct path', async () => {
|
||||||
|
const sample: TagProvidersResponse = {
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
id: 'musicbrainz',
|
||||||
|
display_name: 'MusicBrainz',
|
||||||
|
requires_api_key: false,
|
||||||
|
supports: ['track_tags'],
|
||||||
|
enabled: true,
|
||||||
|
api_key_set: false,
|
||||||
|
display_order: 0,
|
||||||
|
testable: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sources_version: 1
|
||||||
|
};
|
||||||
|
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
|
||||||
|
const got = await getTagProviders();
|
||||||
|
expect(api.get).toHaveBeenCalledWith('/api/admin/tag-sources');
|
||||||
|
expect(got.providers[0].id).toBe('musicbrainz');
|
||||||
|
expect(got.sources_version).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateTagProvider PATCHes the right path with patch body', async () => {
|
||||||
|
const patched: TagProvider & { version_bumped: boolean } = {
|
||||||
|
id: 'lastfm',
|
||||||
|
display_name: 'Last.fm',
|
||||||
|
requires_api_key: true,
|
||||||
|
supports: ['track_tags'],
|
||||||
|
enabled: true,
|
||||||
|
api_key_set: true,
|
||||||
|
display_order: 1,
|
||||||
|
testable: true,
|
||||||
|
version_bumped: true
|
||||||
|
};
|
||||||
|
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(patched);
|
||||||
|
const got = await updateTagProvider('lastfm', { enabled: true, api_key: 'mykey' });
|
||||||
|
expect(api.patch).toHaveBeenCalledWith(
|
||||||
|
'/api/admin/tag-sources/lastfm',
|
||||||
|
{ enabled: true, api_key: 'mykey' }
|
||||||
|
);
|
||||||
|
expect(got.api_key_set).toBe(true);
|
||||||
|
expect(got.version_bumped).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testTagProvider POSTs to /test endpoint with empty body', async () => {
|
||||||
|
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
duration_ms: 55
|
||||||
|
});
|
||||||
|
const got = await testTagProvider('musicbrainz');
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/api/admin/tag-sources/musicbrainz/test', {});
|
||||||
|
expect(got.ok).toBe(true);
|
||||||
|
expect(got.duration_ms).toBe(55);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('testTagProvider returns ok=false with error string on failure', async () => {
|
||||||
|
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
error: 'lastfm api key not set'
|
||||||
|
});
|
||||||
|
const got = await testTagProvider('lastfm');
|
||||||
|
expect(got.ok).toBe(false);
|
||||||
|
expect(got.error).toBe('lastfm api key not set');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user