Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f69c230c4 | ||
|
|
5749f48b4a | ||
|
|
f0c08e7326 | ||
|
|
199fec2058 | ||
|
|
65dd132b3d | ||
|
|
40384cc05e | ||
|
|
40056d2e9a | ||
|
|
2b3be8311a | ||
|
|
c30511e71b | ||
|
|
725ddca950 | ||
|
|
d145fee35d | ||
|
|
611715154b | ||
|
|
23a82fb38d | ||
|
|
0de2437689 | ||
|
|
a251dce7e3 | ||
|
|
938dae7163 | ||
|
|
93365cb555 | ||
|
|
eeabdf1f2c | ||
|
|
3e258507bb | ||
|
|
9550d8daaf | ||
|
|
db393bbe65 | ||
|
|
7b7bd0c3e8 | ||
|
|
8019537b02 | ||
|
|
e41d603c12 | ||
|
|
a62f07bd3a | ||
|
|
3c646c6974 | ||
|
|
9a57dc4bec | ||
|
|
f167ddfbfb | ||
|
|
962b4dbc8c | ||
|
|
aa4089118e | ||
|
|
7c791dc8e4 | ||
|
|
11466e1525 | ||
|
|
301c3bfb86 | ||
|
|
4d8c7d6566 | ||
|
|
d6e6caa223 | ||
|
|
8b08482d13 | ||
|
|
7cf04fe24b | ||
|
|
222a0ff636 | ||
|
|
d75c1ae37f | ||
|
|
3c4c27fb08 | ||
|
|
a62a20b599 | ||
|
|
d9b2dd957c | ||
|
|
46dcd38fd8 | ||
|
|
7838038047 | ||
|
|
b64965b38d | ||
|
|
bf2f9f3811 | ||
|
|
deb726a285 | ||
|
|
7ede83a586 | ||
|
|
1d7b91333f | ||
|
|
883d416d26 | ||
|
|
09471a8f5c | ||
|
|
7de238e91e | ||
|
|
9440c5860b | ||
|
|
082d31bfa9 | ||
|
|
8847b43d9e | ||
|
|
09cc810e5a | ||
|
|
2534384ed1 | ||
|
|
9ff1b30e3f | ||
|
|
c01853577b | ||
|
|
747ed4134b | ||
|
|
ccbd3b62a0 | ||
|
|
19de0c2874 | ||
|
|
22a4649bfc | ||
|
|
8e7660c05e | ||
|
|
53be834e89 | ||
|
|
0d410630a2 | ||
|
|
62db8edcdb | ||
|
|
ec0cc37bc9 | ||
|
|
e772938a3b | ||
|
|
29fee5aa37 | ||
|
|
e5ab471ce1 | ||
|
|
573aa4226d | ||
|
|
7339815ea9 | ||
|
|
baa601765e | ||
|
|
0d009b34e2 | ||
|
|
f1b4652c77 | ||
|
|
e610948307 | ||
|
|
fb811804d2 | ||
|
|
37134950a5 | ||
|
|
fcded9294c | ||
|
|
42abb7adff | ||
|
|
04933f2d9f | ||
|
|
626fc7502c | ||
|
|
9bf3b8a2f2 | ||
|
|
492460cf4a | ||
|
|
1c775905d7 | ||
|
|
cd2ff648d0 |
+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 com.composables.icons.lucide.Inbox
|
||||
import com.composables.icons.lucide.Lucide
|
||||
import com.composables.icons.lucide.Music
|
||||
import com.composables.icons.lucide.TriangleAlert
|
||||
import com.composables.icons.lucide.Users
|
||||
import com.fabledsword.minstrel.admin.data.AdminQuarantineRepository
|
||||
import com.fabledsword.minstrel.admin.data.AdminRequestsRepository
|
||||
import com.fabledsword.minstrel.admin.data.AdminTagSourcesRepository
|
||||
import com.fabledsword.minstrel.admin.data.AdminUsersRepository
|
||||
import com.fabledsword.minstrel.api.ErrorCopy
|
||||
import com.fabledsword.minstrel.nav.Admin
|
||||
import com.fabledsword.minstrel.nav.AdminQuarantine
|
||||
import com.fabledsword.minstrel.nav.AdminRequests
|
||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||
import com.fabledsword.minstrel.nav.AdminUsers
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
@@ -54,7 +57,7 @@ import javax.inject.Inject
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────
|
||||
|
||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int)
|
||||
data class AdminCounts(val requests: Int, val quarantine: Int, val users: Int, val tagSources: Int)
|
||||
|
||||
sealed interface AdminLandingUiState {
|
||||
data object Loading : AdminLandingUiState
|
||||
@@ -69,6 +72,7 @@ class AdminLandingViewModel @Inject constructor(
|
||||
private val requestsRepo: AdminRequestsRepository,
|
||||
private val quarantineRepo: AdminQuarantineRepository,
|
||||
private val usersRepo: AdminUsersRepository,
|
||||
private val tagSourcesRepo: AdminTagSourcesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val internal = MutableStateFlow<AdminLandingUiState>(AdminLandingUiState.Loading)
|
||||
@@ -85,7 +89,8 @@ class AdminLandingViewModel @Inject constructor(
|
||||
val req = async { requestsRepo.list().size }
|
||||
val qua = async { quarantineRepo.list().size }
|
||||
val usr = async { usersRepo.list().size }
|
||||
AdminCounts(req.await(), qua.await(), usr.await())
|
||||
val tag = async { tagSourcesRepo.list().count { it.enabled } }
|
||||
AdminCounts(req.await(), qua.await(), usr.await(), tag.await())
|
||||
}
|
||||
internal.value = AdminLandingUiState.Success(counts)
|
||||
} catch (
|
||||
@@ -170,6 +175,15 @@ private fun SectionList(counts: AdminCounts, navController: NavHostController) {
|
||||
onClick = { navController.navigate(AdminUsers) },
|
||||
)
|
||||
}
|
||||
item {
|
||||
SectionCard(
|
||||
icon = Lucide.Music,
|
||||
title = "Tag sources",
|
||||
subtitle = "Metadata enrichment providers",
|
||||
count = counts.tagSources,
|
||||
onClick = { navController.navigate(AdminTagSources) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package com.fabledsword.minstrel.admin.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import com.fabledsword.minstrel.models.AdminTagSourceRef
|
||||
import com.fabledsword.minstrel.models.TagSourceTestResult
|
||||
import com.fabledsword.minstrel.nav.AdminTagSources
|
||||
import com.fabledsword.minstrel.shared.widgets.EmptyState
|
||||
import com.fabledsword.minstrel.shared.widgets.ErrorRetry
|
||||
import com.fabledsword.minstrel.shared.widgets.LoadingCentered
|
||||
import com.fabledsword.minstrel.shared.widgets.MinstrelTopAppBar
|
||||
import com.fabledsword.minstrel.shared.widgets.PullToRefreshScaffold
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdminTagSourcesScreen(
|
||||
navController: NavHostController,
|
||||
viewModel: AdminTagSourcesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
MinstrelTopAppBar(
|
||||
title = "Admin · Tag sources",
|
||||
navController = navController,
|
||||
currentRouteName = AdminTagSources::class.qualifiedName,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
PullToRefreshScaffold(
|
||||
onRefresh = { viewModel.refresh().join() },
|
||||
modifier = Modifier.fillMaxSize().padding(inner),
|
||||
) {
|
||||
when (val s = state) {
|
||||
AdminTagSourcesUiState.Loading -> LoadingCentered()
|
||||
AdminTagSourcesUiState.Empty -> EmptyState(
|
||||
title = "No tag sources",
|
||||
body = "Tag-enrichment providers register on the server; none are available.",
|
||||
)
|
||||
is AdminTagSourcesUiState.Error -> ErrorRetry(
|
||||
title = "Couldn't load tag sources",
|
||||
message = s.message,
|
||||
onRetry = { viewModel.refresh() },
|
||||
)
|
||||
is AdminTagSourcesUiState.Success -> TagSourceList(
|
||||
providers = s.providers,
|
||||
testResults = s.testResults,
|
||||
onToggle = viewModel::setEnabled,
|
||||
onSaveKey = viewModel::saveApiKey,
|
||||
onTest = viewModel::test,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagSourceList(
|
||||
providers: List<AdminTagSourceRef>,
|
||||
testResults: Map<String, TagSourceTestResult>,
|
||||
onToggle: (String, Boolean) -> Unit,
|
||||
onSaveKey: (String, String) -> Unit,
|
||||
onTest: (String) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(items = providers, key = { it.id }) { provider ->
|
||||
TagSourceCard(
|
||||
provider = provider,
|
||||
testResult = testResults[provider.id],
|
||||
onToggle = onToggle,
|
||||
onSaveKey = onSaveKey,
|
||||
onTest = onTest,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TagSourceCard(
|
||||
provider: AdminTagSourceRef,
|
||||
testResult: TagSourceTestResult?,
|
||||
onToggle: (String, Boolean) -> Unit,
|
||||
onSaveKey: (String, String) -> Unit,
|
||||
onTest: (String) -> Unit,
|
||||
) {
|
||||
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(provider.displayName, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
text = provider.supports.joinToString(", ") { it.replace('_', ' ') },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Switch(
|
||||
checked = provider.enabled,
|
||||
onCheckedChange = { onToggle(provider.id, it) },
|
||||
)
|
||||
}
|
||||
if (provider.requiresApiKey) {
|
||||
ApiKeyRow(provider = provider, onSaveKey = onSaveKey)
|
||||
}
|
||||
if (provider.testable) {
|
||||
TestRow(provider = provider, testResult = testResult, onTest = onTest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ApiKeyRow(provider: AdminTagSourceRef, onSaveKey: (String, String) -> Unit) {
|
||||
var key by remember(provider.id) { mutableStateOf("") }
|
||||
OutlinedTextField(
|
||||
value = key,
|
||||
onValueChange = { key = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("API key") },
|
||||
placeholder = {
|
||||
Text(
|
||||
if (provider.apiKeySet) {
|
||||
"••• saved — leave blank to keep"
|
||||
} else {
|
||||
"Paste your API key to enable this source"
|
||||
},
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = {
|
||||
onSaveKey(provider.id, key)
|
||||
key = ""
|
||||
},
|
||||
enabled = key.isNotBlank(),
|
||||
) { Text("Save key") }
|
||||
if (provider.apiKeySet) {
|
||||
Text("✓ Set", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestRow(
|
||||
provider: AdminTagSourceRef,
|
||||
testResult: TagSourceTestResult?,
|
||||
onTest: (String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedButton(onClick = { onTest(provider.id) }) { Text("Test connection") }
|
||||
testResult?.let { result ->
|
||||
if (result.ok) {
|
||||
Text(
|
||||
text = "OK (${result.durationMs}ms)",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = "Failed — ${result.error}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+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 durationPlayedMs: Long,
|
||||
val source: String? = null,
|
||||
// #1551: device class for context conditioning; null on payloads queued
|
||||
// before this field existed (decodes to null → server stores NULL).
|
||||
val deviceClass: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
+1
@@ -254,6 +254,7 @@ class MutationReplayer @Inject constructor(
|
||||
at = decoded.atIso,
|
||||
durationPlayedMs = decoded.durationPlayedMs,
|
||||
source = decoded.source,
|
||||
deviceClass = decoded.deviceClass,
|
||||
),
|
||||
)
|
||||
return Outcome.SENT
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.fabledsword.minstrel.models
|
||||
|
||||
/** Domain model for one tag-enrichment provider in the admin surface (#1521). */
|
||||
data class AdminTagSourceRef(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val requiresApiKey: Boolean,
|
||||
val supports: List<String>,
|
||||
val enabled: Boolean,
|
||||
val apiKeySet: Boolean,
|
||||
val testable: Boolean,
|
||||
)
|
||||
|
||||
/** Result of a provider "test connection" call. */
|
||||
data class TagSourceTestResult(
|
||||
val ok: Boolean,
|
||||
val durationMs: Long = 0,
|
||||
val error: String = "",
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.fabledsword.minstrel.models.wire
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire DTOs for the `/api/admin/tag-sources` admin surface (#1521).
|
||||
* Every field defaults defensively so a missing JSON key never crashes
|
||||
* deserialization. `versionBumped` is present only on the PATCH response.
|
||||
*/
|
||||
@Serializable
|
||||
data class AdminTagSourceWire(
|
||||
val id: String = "",
|
||||
@SerialName("display_name") val displayName: String = "",
|
||||
@SerialName("requires_api_key") val requiresApiKey: Boolean = false,
|
||||
val supports: List<String> = emptyList(),
|
||||
val enabled: Boolean = false,
|
||||
@SerialName("api_key_set") val apiKeySet: Boolean = false,
|
||||
@SerialName("display_order") val displayOrder: Int = 0,
|
||||
val testable: Boolean = false,
|
||||
@SerialName("version_bumped") val versionBumped: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AdminTagSourcesListWire(
|
||||
val providers: List<AdminTagSourceWire> = emptyList(),
|
||||
@SerialName("sources_version") val sourcesVersion: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TestTagSourceWire(
|
||||
val ok: Boolean = false,
|
||||
@SerialName("duration_ms") val durationMs: Long = 0,
|
||||
val error: String = "",
|
||||
)
|
||||
@@ -27,6 +27,8 @@ data class PlayStartedRequest(
|
||||
@SerialName("track_id") val trackId: String,
|
||||
@SerialName("client_id") val clientId: String,
|
||||
val source: String? = null,
|
||||
// #1551: device class for context conditioning (server normalizes).
|
||||
@SerialName("device_class") val deviceClass: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -56,4 +58,6 @@ data class PlayOfflineRequest(
|
||||
val at: String,
|
||||
@SerialName("duration_played_ms") val durationPlayedMs: Long,
|
||||
val source: String? = null,
|
||||
// #1551: device class for context conditioning (server normalizes).
|
||||
@SerialName("device_class") val deviceClass: String? = null,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ import androidx.navigation.compose.composable
|
||||
import com.fabledsword.minstrel.admin.ui.AdminLandingScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminQuarantineScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminRequestsScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminTagSourcesScreen
|
||||
import com.fabledsword.minstrel.admin.ui.AdminUsersScreen
|
||||
import com.fabledsword.minstrel.auth.ui.LoginScreen
|
||||
import com.fabledsword.minstrel.auth.ui.ServerUrlScreen
|
||||
@@ -190,6 +191,13 @@ private fun NavGraphBuilder.inShellDetail(
|
||||
}
|
||||
}
|
||||
}
|
||||
composable<AdminTagSources> {
|
||||
WithAnimatedScope {
|
||||
ShellScaffold(onExpandPlayer = expandPlayer) {
|
||||
AdminTagSourcesScreen(navController = navController)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavGraphBuilder.outsideShell(navController: NavHostController) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable data object AdminRequests
|
||||
@Serializable data object AdminQuarantine
|
||||
@Serializable data object AdminUsers
|
||||
@Serializable data object AdminTagSources
|
||||
|
||||
// ── Outside-shell full-screen routes ──────────────────────────────────
|
||||
|
||||
|
||||
@@ -155,6 +155,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
trackId = trackId,
|
||||
clientId = cid,
|
||||
source = source.takeIf { !it.isNullOrEmpty() },
|
||||
deviceClass = DEVICE_CLASS,
|
||||
),
|
||||
)
|
||||
if (curTrackId == trackId) {
|
||||
@@ -248,6 +249,7 @@ class PlayEventsReporter @Inject constructor(
|
||||
atIso = startedAt.toString(),
|
||||
durationPlayedMs = durationPlayedMs,
|
||||
source = source.takeIf { !it.isNullOrEmpty() },
|
||||
deviceClass = DEVICE_CLASS,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -268,6 +270,12 @@ class PlayEventsReporter @Inject constructor(
|
||||
|
||||
// ── Lifecycle: durable-close on app background / detach ────────
|
||||
|
||||
companion object {
|
||||
// #1551: Android is a mobile client. Refine to tablet/tv via device
|
||||
// configuration later if the metrics trend view shows it matters.
|
||||
private const val DEVICE_CLASS = "mobile"
|
||||
}
|
||||
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
// App backgrounded. If the tracked play is still PLAYING, leave it
|
||||
// alone: the foreground media service keeps the process — and this
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/config"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coplay"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/coverart"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/eventbus"
|
||||
@@ -206,6 +207,12 @@ func run() error {
|
||||
similarityWorker := similarity.NewWorker(pool, listenbrainz.NewClient(), logger.With("component", "similarity"))
|
||||
go similarityWorker.Run(ctx)
|
||||
|
||||
// Start the household co-play worker (#1533). Recomputes artist–artist
|
||||
// co-occurrence edges (source='user_cooccurrence') from play_events every
|
||||
// 6h — a collaborative candidate arm for the radio/mix pools. Pure local
|
||||
// SQL, no external calls; empty on single-user servers.
|
||||
go coplay.NewWorker(pool, logger.With("component", "coplay")).Run(ctx)
|
||||
|
||||
// Start the tag-enrichment worker (#1490). Reconciles the compiled-in
|
||||
// tag providers with tag_provider_settings, bumps the sources version if
|
||||
// the provider set changed (re-opening settled rows), then drains tracks
|
||||
|
||||
@@ -20,26 +20,28 @@ import (
|
||||
// weightsResp is one weight profile on the wire, keyed by the same
|
||||
// snake_case field names the PATCH body accepts.
|
||||
type weightsResp struct {
|
||||
BaseWeight float64 `json:"base_weight"`
|
||||
LikeBoost float64 `json:"like_boost"`
|
||||
RecencyWeight float64 `json:"recency_weight"`
|
||||
SkipPenalty float64 `json:"skip_penalty"`
|
||||
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||
ContextWeight float64 `json:"context_weight"`
|
||||
SimilarityWeight float64 `json:"similarity_weight"`
|
||||
TasteWeight float64 `json:"taste_weight"`
|
||||
BaseWeight float64 `json:"base_weight"`
|
||||
LikeBoost float64 `json:"like_boost"`
|
||||
RecencyWeight float64 `json:"recency_weight"`
|
||||
SkipPenalty float64 `json:"skip_penalty"`
|
||||
JitterMagnitude float64 `json:"jitter_magnitude"`
|
||||
ContextWeight float64 `json:"context_weight"`
|
||||
SimilarityWeight float64 `json:"similarity_weight"`
|
||||
TasteWeight float64 `json:"taste_weight"`
|
||||
ContextTimeWeight float64 `json:"context_time_weight"`
|
||||
}
|
||||
|
||||
func weightsRespFrom(w recommendation.ScoringWeights) weightsResp {
|
||||
return weightsResp{
|
||||
BaseWeight: w.BaseWeight,
|
||||
LikeBoost: w.LikeBoost,
|
||||
RecencyWeight: w.RecencyWeight,
|
||||
SkipPenalty: w.SkipPenalty,
|
||||
JitterMagnitude: w.JitterMagnitude,
|
||||
ContextWeight: w.ContextWeight,
|
||||
SimilarityWeight: w.SimilarityWeight,
|
||||
TasteWeight: w.TasteWeight,
|
||||
BaseWeight: w.BaseWeight,
|
||||
LikeBoost: w.LikeBoost,
|
||||
RecencyWeight: w.RecencyWeight,
|
||||
SkipPenalty: w.SkipPenalty,
|
||||
JitterMagnitude: w.JitterMagnitude,
|
||||
ContextWeight: w.ContextWeight,
|
||||
SimilarityWeight: w.SimilarityWeight,
|
||||
TasteWeight: w.TasteWeight,
|
||||
ContextTimeWeight: w.ContextTimeWeight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +50,9 @@ type tasteTuningResp struct {
|
||||
EngagementHardSkip float64 `json:"engagement_hard_skip"`
|
||||
EngagementNeutral float64 `json:"engagement_neutral"`
|
||||
EngagementFull float64 `json:"engagement_full"`
|
||||
EnrichedTagScale float64 `json:"enriched_tag_scale"`
|
||||
EraScale float64 `json:"era_scale"`
|
||||
MoodScale float64 `json:"mood_scale"`
|
||||
}
|
||||
|
||||
func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||
@@ -56,6 +61,9 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
|
||||
EngagementHardSkip: t.EngagementHardSkip,
|
||||
EngagementNeutral: t.EngagementNeutral,
|
||||
EngagementFull: t.EngagementFull,
|
||||
EnrichedTagScale: t.EnrichedTagScale,
|
||||
EraScale: t.EraScale,
|
||||
MoodScale: t.MoodScale,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-2
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -24,6 +25,33 @@ type eventRequest struct {
|
||||
// / "discover" feed the system-playlist rotation dedup (#415);
|
||||
// absent / "" for library, user-playlist, radio, Subsonic.
|
||||
Source *string `json:"source"`
|
||||
// DeviceClass is the client's self-reported device class (#1551),
|
||||
// normalized + stored per play to condition radio on the current device.
|
||||
DeviceClass *string `json:"device_class"`
|
||||
}
|
||||
|
||||
// knownDeviceClasses whitelists the device classes the context-affinity facet
|
||||
// buckets by (#1551). A client sends one; anything unrecognized normalizes to
|
||||
// "other", and empty/absent to "" (stored NULL → excluded from the device
|
||||
// dimension). Kept permissive (no DB CHECK) so a new client class is one
|
||||
// whitelist entry, not a migration.
|
||||
var knownDeviceClasses = map[string]bool{
|
||||
"mobile": true, "tablet": true, "desktop": true,
|
||||
"web": true, "tv": true, "watch": true,
|
||||
}
|
||||
|
||||
func normalizeDeviceClass(dc *string) string {
|
||||
if dc == nil {
|
||||
return ""
|
||||
}
|
||||
s := strings.ToLower(strings.TrimSpace(*dc))
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if knownDeviceClasses[s] {
|
||||
return s
|
||||
}
|
||||
return "other"
|
||||
}
|
||||
|
||||
type playStartedResponse struct {
|
||||
@@ -114,7 +142,9 @@ func (h *handlers) handleEventPlayStarted(
|
||||
if req.Source != nil {
|
||||
source = *req.Source
|
||||
}
|
||||
res, err := h.events.RecordPlayStartedWithSource(r.Context(), user.ID, trackID, clientID, source, at)
|
||||
deviceClass := normalizeDeviceClass(req.DeviceClass)
|
||||
res, err := h.events.RecordPlayStartedWithSource(
|
||||
r.Context(), user.ID, trackID, clientID, source, deviceClass, at)
|
||||
if err != nil {
|
||||
h.logger.Error("api: events: play_started", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||
@@ -159,7 +189,8 @@ func (h *handlers) handleEventPlayOffline(
|
||||
source = *req.Source
|
||||
}
|
||||
if err := h.events.RecordOfflinePlay(
|
||||
r.Context(), user.ID, trackID, clientID, source, at, *req.DurationPlayedMs,
|
||||
r.Context(), user.ID, trackID, clientID, source,
|
||||
normalizeDeviceClass(req.DeviceClass), at, *req.DurationPlayedMs,
|
||||
); err != nil {
|
||||
h.logger.Error("api: events: play_offline", "err", err)
|
||||
writeErr(w, apierror.InternalMsg("record failed", err))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
@@ -81,6 +82,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
|
||||
// Condition on the current device (#1551): the latest play's device is a
|
||||
// cheap, request-free proxy for what the user is on right now.
|
||||
currentVec.DeviceClass = latestDeviceClass(r.Context(), q, user.ID, h.logger)
|
||||
|
||||
exclude := parseExcludeParam(r.URL.Query().Get("exclude"))
|
||||
limits := recommendation.DefaultCandidateSourceLimits()
|
||||
@@ -150,6 +154,24 @@ func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUI
|
||||
return v
|
||||
}
|
||||
|
||||
// latestDeviceClass returns the device_class of the user's most recent play as
|
||||
// the "current device" for context conditioning (#1551), or "" when unknown
|
||||
// (no plays yet, or the latest play predates device capture). Best-effort: a
|
||||
// lookup failure yields a device-agnostic ("") affinity cell.
|
||||
func latestDeviceClass(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) string {
|
||||
dc, err := q.GetLatestPlayDeviceClassForUser(ctx, userID)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
logger.Warn("api: radio: latest device class", "err", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if dc == nil {
|
||||
return ""
|
||||
}
|
||||
return *dc
|
||||
}
|
||||
|
||||
// parseExcludeParam parses a comma-separated list of UUIDs from the
|
||||
// `exclude` query string, silently dropping malformed entries. Returns
|
||||
// nil for empty or all-malformed input.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
|
||||
}
|
||||
|
||||
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events
|
||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events
|
||||
WHERE user_id = $1 AND ended_at IS NULL
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
@@ -80,12 +80,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
&i.DeviceClass,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events WHERE id = $1
|
||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class FROM play_events WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
||||
@@ -106,6 +107,7 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
&i.DeviceClass,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -144,19 +146,22 @@ func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPi
|
||||
|
||||
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
||||
INSERT INTO play_events (
|
||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
|
||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
|
||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
|
||||
device_class
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::text,
|
||||
$8::text)
|
||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
|
||||
`
|
||||
|
||||
type InsertPlayEventParams struct {
|
||||
UserID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
SessionID pgtype.UUID
|
||||
StartedAt pgtype.Timestamptz
|
||||
ClientID *string
|
||||
Source *string
|
||||
PickKind *string
|
||||
UserID pgtype.UUID
|
||||
TrackID pgtype.UUID
|
||||
SessionID pgtype.UUID
|
||||
StartedAt pgtype.Timestamptz
|
||||
ClientID *string
|
||||
Source *string
|
||||
PickKind *string
|
||||
DeviceClass *string
|
||||
}
|
||||
|
||||
// pick_kind is non-NULL only for system-playlist plays whose track was
|
||||
@@ -171,6 +176,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
||||
arg.ClientID,
|
||||
arg.Source,
|
||||
arg.PickKind,
|
||||
arg.DeviceClass,
|
||||
)
|
||||
var i PlayEvent
|
||||
err := row.Scan(
|
||||
@@ -188,6 +194,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
&i.DeviceClass,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -333,7 +340,7 @@ SET ended_at = $2,
|
||||
completion_ratio = $4,
|
||||
was_skipped = $5
|
||||
WHERE id = $1
|
||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
|
||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind, device_class
|
||||
`
|
||||
|
||||
type UpdatePlayEventEndedParams struct {
|
||||
@@ -371,6 +378,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
&i.DeviceClass,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
+29
-10
@@ -384,6 +384,7 @@ type PlayEvent struct {
|
||||
ScrobbledAt pgtype.Timestamptz
|
||||
Source *string
|
||||
PickKind *string
|
||||
DeviceClass *string
|
||||
}
|
||||
|
||||
type PlaySession struct {
|
||||
@@ -446,16 +447,17 @@ type RecommendationTuningAudit struct {
|
||||
}
|
||||
|
||||
type RecommendationWeightProfile struct {
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
ContextTimeWeight float64
|
||||
}
|
||||
|
||||
type RegistrationSetting struct {
|
||||
@@ -554,6 +556,20 @@ type TasteProfileArtist struct {
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileEra struct {
|
||||
UserID pgtype.UUID
|
||||
Era string
|
||||
Weight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileMood struct {
|
||||
UserID pgtype.UUID
|
||||
Mood string
|
||||
Weight float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type TasteProfileTag struct {
|
||||
UserID pgtype.UUID
|
||||
Tag string
|
||||
@@ -568,6 +584,9 @@ type TasteTuning struct {
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
|
||||
@@ -11,6 +11,110 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const getLatestPlayDeviceClassForUser = `-- name: GetLatestPlayDeviceClassForUser :one
|
||||
SELECT device_class
|
||||
FROM play_events
|
||||
WHERE user_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
// The device_class of the user's most recent play (#1551), used as the
|
||||
// "current device" for radio context conditioning. NULL when the latest play
|
||||
// predates device capture or came from a client that didn't send one.
|
||||
func (q *Queries) GetLatestPlayDeviceClassForUser(ctx context.Context, userID pgtype.UUID) (*string, error) {
|
||||
row := q.db.QueryRow(ctx, getLatestPlayDeviceClassForUser, userID)
|
||||
var device_class *string
|
||||
err := row.Scan(&device_class)
|
||||
return device_class, err
|
||||
}
|
||||
|
||||
const listArtistContextPlayCountsForUser = `-- name: ListArtistContextPlayCountsForUser :many
|
||||
WITH tz AS (
|
||||
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
|
||||
FROM users u WHERE u.id = $1
|
||||
),
|
||||
now_cell AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
|
||||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
|
||||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
|
||||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
|
||||
ELSE 3
|
||||
END AS daypart,
|
||||
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||||
FROM tz
|
||||
),
|
||||
plays AS (
|
||||
SELECT t.artist_id,
|
||||
pe.device_class,
|
||||
CASE
|
||||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
|
||||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
|
||||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
|
||||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
|
||||
ELSE 3
|
||||
END AS daypart,
|
||||
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
CROSS JOIN tz
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.was_skipped = false
|
||||
AND pe.started_at > now() - interval '365 days'
|
||||
)
|
||||
SELECT p.artist_id,
|
||||
count(*) AS total_plays,
|
||||
count(*) FILTER (
|
||||
WHERE p.daypart = (SELECT daypart FROM now_cell)
|
||||
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
|
||||
AND ($2::text = '' OR p.device_class = $2::text)
|
||||
) AS cell_plays
|
||||
FROM plays p
|
||||
GROUP BY p.artist_id
|
||||
`
|
||||
|
||||
type ListArtistContextPlayCountsForUserParams struct {
|
||||
ID pgtype.UUID
|
||||
Column2 string
|
||||
}
|
||||
|
||||
type ListArtistContextPlayCountsForUserRow struct {
|
||||
ArtistID pgtype.UUID
|
||||
TotalPlays int64
|
||||
CellPlays int64
|
||||
}
|
||||
|
||||
// Per-artist completed-play counts split by whether each play falls in the
|
||||
// CURRENT context cell, in the user's local timezone (#1531). The cell is
|
||||
// daypart × weekday-type, optionally narrowed by device class (#1551): when
|
||||
// $2 (the current device) is ”, the device dimension is ignored (identical to
|
||||
// the time-only #1531 behaviour, used by the daily mixes which have no device);
|
||||
// when $2 is set (radio), a play only counts in the cell if its device_class
|
||||
// matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
|
||||
// window bounds cost. Daypart buckets: night [22,5) morning [5,12)
|
||||
// afternoon [12,17) evening [17,22). Weekend = ISO days 6–7 (Sat/Sun).
|
||||
func (q *Queries) ListArtistContextPlayCountsForUser(ctx context.Context, arg ListArtistContextPlayCountsForUserParams) ([]ListArtistContextPlayCountsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listArtistContextPlayCountsForUser, arg.ID, arg.Column2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListArtistContextPlayCountsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListArtistContextPlayCountsForUserRow
|
||||
if err := rows.Scan(&i.ArtistID, &i.TotalPlays, &i.CellPlays); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listLastPlayedArtistsForUser = `-- name: ListLastPlayedArtistsForUser :many
|
||||
WITH user_plays AS (
|
||||
SELECT t.artist_id, max(pe.started_at) AS last_started
|
||||
@@ -585,8 +689,10 @@ SELECT
|
||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
@@ -620,6 +726,7 @@ type LoadRadioCandidatesRow struct {
|
||||
LastPlayedAt pgtype.Timestamptz
|
||||
PlayCount int64
|
||||
SkipCount int64
|
||||
ReleaseDate pgtype.Date
|
||||
}
|
||||
|
||||
// Returns all tracks except the seed and any played by the user within
|
||||
@@ -660,6 +767,7 @@ func (q *Queries) LoadRadioCandidates(ctx context.Context, arg LoadRadioCandidat
|
||||
&i.LastPlayedAt,
|
||||
&i.PlayCount,
|
||||
&i.SkipCount,
|
||||
&i.ReleaseDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -754,6 +862,22 @@ taste_overlap AS (
|
||||
ORDER BY tpa.weight DESC, t.id
|
||||
LIMIT $10
|
||||
),
|
||||
coplay_artists AS (
|
||||
-- Household co-play (#1533): tracks by artists co-played across the
|
||||
-- instance with the seed's artist (source='user_cooccurrence', built by
|
||||
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
||||
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
||||
-- similar_artists since it's artist-level.
|
||||
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
||||
FROM artist_similarity asim
|
||||
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
||||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||||
WHERE asim.source = 'user_cooccurrence'
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
AND t.id <> $2
|
||||
ORDER BY asim.score DESC, random()
|
||||
LIMIT $11
|
||||
),
|
||||
random_fill AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -765,6 +889,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
UNION SELECT track_id FROM coplay_artists
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -775,6 +900,7 @@ SELECT
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date,
|
||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||
FROM (
|
||||
SELECT track_id, sim_score FROM lb_similar
|
||||
@@ -782,9 +908,11 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(started_at) AS last_played_at,
|
||||
@@ -796,7 +924,8 @@ LEFT JOIN LATERAL (
|
||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
|
||||
al.release_date
|
||||
`
|
||||
|
||||
type LoadRadioCandidatesV2Params struct {
|
||||
@@ -810,6 +939,7 @@ type LoadRadioCandidatesV2Params struct {
|
||||
Limit_4 int32
|
||||
Limit_5 int32
|
||||
Limit_6 int32
|
||||
Limit_7 int32
|
||||
}
|
||||
|
||||
type LoadRadioCandidatesV2Row struct {
|
||||
@@ -818,6 +948,7 @@ type LoadRadioCandidatesV2Row struct {
|
||||
LastPlayedAt pgtype.Timestamptz
|
||||
PlayCount int64
|
||||
SkipCount int64
|
||||
ReleaseDate pgtype.Date
|
||||
SimilarityScore interface{}
|
||||
}
|
||||
|
||||
@@ -829,7 +960,9 @@ type LoadRadioCandidatesV2Row struct {
|
||||
// $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||
// positively-weighted taste-profile artists, so taste-relevant tracks
|
||||
// enter the pool even when the similarity/random arms miss them; scored
|
||||
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||
// in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
|
||||
// $11 coplay_artists K (#1533 — tracks by artists co-played across the
|
||||
// instance with the seed's artist; source='user_cooccurrence').
|
||||
//
|
||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||
@@ -844,6 +977,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
arg.Limit_4,
|
||||
arg.Limit_5,
|
||||
arg.Limit_6,
|
||||
arg.Limit_7,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -874,6 +1008,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
||||
&i.LastPlayedAt,
|
||||
&i.PlayCount,
|
||||
&i.SkipCount,
|
||||
&i.ReleaseDate,
|
||||
&i.SimilarityScore,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
const getTasteTuning = `-- name: GetTasteTuning :one
|
||||
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at FROM taste_tuning WHERE singleton = true
|
||||
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
|
||||
`
|
||||
|
||||
func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||
@@ -23,6 +23,9 @@ func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) {
|
||||
&i.EngagementNeutral,
|
||||
&i.EngagementFull,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrichedTagScale,
|
||||
&i.EraScale,
|
||||
&i.MoodScale,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -80,7 +83,7 @@ func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]Recommend
|
||||
}
|
||||
|
||||
const listWeightProfiles = `-- name: ListWeightProfiles :many
|
||||
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile
|
||||
SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight FROM recommendation_weight_profiles ORDER BY profile
|
||||
`
|
||||
|
||||
func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) {
|
||||
@@ -103,6 +106,7 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
|
||||
&i.SimilarityWeight,
|
||||
&i.TasteWeight,
|
||||
&i.UpdatedAt,
|
||||
&i.ContextTimeWeight,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -120,9 +124,12 @@ UPDATE taste_tuning
|
||||
engagement_hard_skip = $2,
|
||||
engagement_neutral = $3,
|
||||
engagement_full = $4,
|
||||
enriched_tag_scale = $5,
|
||||
era_scale = $6,
|
||||
mood_scale = $7,
|
||||
updated_at = now()
|
||||
WHERE singleton = true
|
||||
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at
|
||||
RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale
|
||||
`
|
||||
|
||||
type UpdateTasteTuningParams struct {
|
||||
@@ -130,6 +137,9 @@ type UpdateTasteTuningParams struct {
|
||||
EngagementHardSkip float64
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) {
|
||||
@@ -138,6 +148,9 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
||||
arg.EngagementHardSkip,
|
||||
arg.EngagementNeutral,
|
||||
arg.EngagementFull,
|
||||
arg.EnrichedTagScale,
|
||||
arg.EraScale,
|
||||
arg.MoodScale,
|
||||
)
|
||||
var i TasteTuning
|
||||
err := row.Scan(
|
||||
@@ -147,35 +160,40 @@ func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningPa
|
||||
&i.EngagementNeutral,
|
||||
&i.EngagementFull,
|
||||
&i.UpdatedAt,
|
||||
&i.EnrichedTagScale,
|
||||
&i.EraScale,
|
||||
&i.MoodScale,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateWeightProfile = `-- name: UpdateWeightProfile :one
|
||||
UPDATE recommendation_weight_profiles
|
||||
SET base_weight = $2,
|
||||
like_boost = $3,
|
||||
recency_weight = $4,
|
||||
skip_penalty = $5,
|
||||
jitter_magnitude = $6,
|
||||
context_weight = $7,
|
||||
similarity_weight = $8,
|
||||
taste_weight = $9,
|
||||
updated_at = now()
|
||||
SET base_weight = $2,
|
||||
like_boost = $3,
|
||||
recency_weight = $4,
|
||||
skip_penalty = $5,
|
||||
jitter_magnitude = $6,
|
||||
context_weight = $7,
|
||||
similarity_weight = $8,
|
||||
taste_weight = $9,
|
||||
context_time_weight = $10,
|
||||
updated_at = now()
|
||||
WHERE profile = $1
|
||||
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at
|
||||
RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at, context_time_weight
|
||||
`
|
||||
|
||||
type UpdateWeightProfileParams struct {
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
ContextTimeWeight float64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) {
|
||||
@@ -189,6 +207,7 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
||||
arg.ContextWeight,
|
||||
arg.SimilarityWeight,
|
||||
arg.TasteWeight,
|
||||
arg.ContextTimeWeight,
|
||||
)
|
||||
var i RecommendationWeightProfile
|
||||
err := row.Scan(
|
||||
@@ -202,6 +221,7 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
||||
&i.SimilarityWeight,
|
||||
&i.TasteWeight,
|
||||
&i.UpdatedAt,
|
||||
&i.ContextTimeWeight,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -209,8 +229,9 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
|
||||
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
|
||||
INSERT INTO taste_tuning (
|
||||
singleton, half_life_days, engagement_hard_skip,
|
||||
engagement_neutral, engagement_full
|
||||
) VALUES (true, $1, $2, $3, $4)
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||
mood_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (singleton) DO NOTHING
|
||||
`
|
||||
|
||||
@@ -219,6 +240,9 @@ type UpsertTasteTuningDefaultsParams struct {
|
||||
EngagementHardSkip float64
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error {
|
||||
@@ -227,6 +251,9 @@ func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTaste
|
||||
arg.EngagementHardSkip,
|
||||
arg.EngagementNeutral,
|
||||
arg.EngagementFull,
|
||||
arg.EnrichedTagScale,
|
||||
arg.EraScale,
|
||||
arg.MoodScale,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -235,21 +262,23 @@ const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec
|
||||
|
||||
INSERT INTO recommendation_weight_profiles (
|
||||
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
jitter_magnitude, context_weight, similarity_weight, taste_weight,
|
||||
context_time_weight
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (profile) DO NOTHING
|
||||
`
|
||||
|
||||
type UpsertWeightProfileDefaultsParams struct {
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
Profile string
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
ContextTimeWeight float64
|
||||
}
|
||||
|
||||
// Recommendation tuning lab queries (#1250). Seeding happens via the
|
||||
@@ -267,6 +296,7 @@ func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWei
|
||||
arg.ContextWeight,
|
||||
arg.SimilarityWeight,
|
||||
arg.TasteWeight,
|
||||
arg.ContextTimeWeight,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -20,6 +20,24 @@ func (q *Queries) DeleteTasteProfileArtistsForUser(ctx context.Context, userID p
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileErasForUser = `-- name: DeleteTasteProfileErasForUser :exec
|
||||
DELETE FROM taste_profile_eras WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteTasteProfileErasForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTasteProfileErasForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileMoodsForUser = `-- name: DeleteTasteProfileMoodsForUser :exec
|
||||
DELETE FROM taste_profile_moods WHERE user_id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteTasteProfileMoodsForUser(ctx context.Context, userID pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, deleteTasteProfileMoodsForUser, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteTasteProfileTagsForUser = `-- name: DeleteTasteProfileTagsForUser :exec
|
||||
DELETE FROM taste_profile_tags WHERE user_id = $1
|
||||
`
|
||||
@@ -45,6 +63,38 @@ func (q *Queries) InsertTasteProfileArtist(ctx context.Context, arg InsertTasteP
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileEra = `-- name: InsertTasteProfileEra :exec
|
||||
INSERT INTO taste_profile_eras (user_id, era, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertTasteProfileEraParams struct {
|
||||
UserID pgtype.UUID
|
||||
Era string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertTasteProfileEra(ctx context.Context, arg InsertTasteProfileEraParams) error {
|
||||
_, err := q.db.Exec(ctx, insertTasteProfileEra, arg.UserID, arg.Era, arg.Weight)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileMood = `-- name: InsertTasteProfileMood :exec
|
||||
INSERT INTO taste_profile_moods (user_id, mood, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
|
||||
type InsertTasteProfileMoodParams struct {
|
||||
UserID pgtype.UUID
|
||||
Mood string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertTasteProfileMood(ctx context.Context, arg InsertTasteProfileMoodParams) error {
|
||||
_, err := q.db.Exec(ctx, insertTasteProfileMood, arg.UserID, arg.Mood, arg.Weight)
|
||||
return err
|
||||
}
|
||||
|
||||
const insertTasteProfileTag = `-- name: InsertTasteProfileTag :exec
|
||||
INSERT INTO taste_profile_tags (user_id, tag, weight)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -87,21 +137,23 @@ func (q *Queries) ListLikedArtistIDsForUser(ctx context.Context, userID pgtype.U
|
||||
}
|
||||
|
||||
const listLikedTrackTasteInputsForUser = `-- name: ListLikedTrackTasteInputsForUser :many
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE gl.user_id = $1
|
||||
`
|
||||
|
||||
type ListLikedTrackTasteInputsForUserRow struct {
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
ReleaseDate pgtype.Date
|
||||
}
|
||||
|
||||
// (track_id, artist_id, genre) for each track the user has explicitly
|
||||
// liked. Feeds the track-like bonus into the liked track's artist and
|
||||
// tags; track_id keys the enriched track_tags lookup (#1490).
|
||||
// (track_id, artist_id, genre, release_date) for each track the user has
|
||||
// explicitly liked. Feeds the track-like bonus into the liked track's artist,
|
||||
// tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
|
||||
func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID pgtype.UUID) ([]ListLikedTrackTasteInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listLikedTrackTasteInputsForUser, userID)
|
||||
if err != nil {
|
||||
@@ -111,7 +163,12 @@ func (q *Queries) ListLikedTrackTasteInputsForUser(ctx context.Context, userID p
|
||||
var items []ListLikedTrackTasteInputsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListLikedTrackTasteInputsForUserRow
|
||||
if err := rows.Scan(&i.TrackID, &i.ArtistID, &i.Genre); err != nil {
|
||||
if err := rows.Scan(
|
||||
&i.TrackID,
|
||||
&i.ArtistID,
|
||||
&i.Genre,
|
||||
&i.ReleaseDate,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
@@ -128,6 +185,7 @@ SELECT
|
||||
t.id AS track_id,
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
a.release_date,
|
||||
LEAST(GREATEST(
|
||||
COALESCE(pe.completion_ratio,
|
||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
||||
@@ -135,6 +193,7 @@ SELECT
|
||||
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
AND NOT EXISTS (
|
||||
@@ -149,11 +208,12 @@ type ListPlayEngagementInputsForUserParams struct {
|
||||
}
|
||||
|
||||
type ListPlayEngagementInputsForUserRow struct {
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
Completion float64
|
||||
AgeDays float64
|
||||
TrackID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
Genre *string
|
||||
ReleaseDate pgtype.Date
|
||||
Completion float64
|
||||
AgeDays float64
|
||||
}
|
||||
|
||||
// Taste profile (#796 phase 1). The daily build computes signed artist/tag
|
||||
@@ -162,7 +222,8 @@ type ListPlayEngagementInputsForUserRow struct {
|
||||
// One row per play in the decay-relevant window. completion is the play's
|
||||
// completion ratio (precomputed column when present, else duration_played /
|
||||
// track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
// is split into tags in Go. Quarantined tracks are excluded.
|
||||
// is split into tags in Go; release_date derives the decade for the era
|
||||
// facet (#1530). Quarantined tracks are excluded.
|
||||
func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListPlayEngagementInputsForUserParams) ([]ListPlayEngagementInputsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listPlayEngagementInputsForUser, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
@@ -176,6 +237,7 @@ func (q *Queries) ListPlayEngagementInputsForUser(ctx context.Context, arg ListP
|
||||
&i.TrackID,
|
||||
&i.ArtistID,
|
||||
&i.Genre,
|
||||
&i.ReleaseDate,
|
||||
&i.Completion,
|
||||
&i.AgeDays,
|
||||
); err != nil {
|
||||
@@ -228,6 +290,84 @@ func (q *Queries) ListTasteProfileArtistsForUser(ctx context.Context, arg ListTa
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileErasForUser = `-- name: ListTasteProfileErasForUser :many
|
||||
SELECT era, weight
|
||||
FROM taste_profile_eras
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListTasteProfileErasForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListTasteProfileErasForUserRow struct {
|
||||
Era string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// Top-weighted taste eras (#1530); consumed by the scorer's era term.
|
||||
func (q *Queries) ListTasteProfileErasForUser(ctx context.Context, arg ListTasteProfileErasForUserParams) ([]ListTasteProfileErasForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTasteProfileErasForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTasteProfileErasForUserRow
|
||||
for rows.Next() {
|
||||
var i ListTasteProfileErasForUserRow
|
||||
if err := rows.Scan(&i.Era, &i.Weight); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileMoodsForUser = `-- name: ListTasteProfileMoodsForUser :many
|
||||
SELECT mood, weight
|
||||
FROM taste_profile_moods
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListTasteProfileMoodsForUserParams struct {
|
||||
UserID pgtype.UUID
|
||||
Limit int32
|
||||
}
|
||||
|
||||
type ListTasteProfileMoodsForUserRow struct {
|
||||
Mood string
|
||||
Weight float64
|
||||
}
|
||||
|
||||
// Top-weighted taste moods (#1534); consumed by the scorer's mood term.
|
||||
func (q *Queries) ListTasteProfileMoodsForUser(ctx context.Context, arg ListTasteProfileMoodsForUserParams) ([]ListTasteProfileMoodsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, listTasteProfileMoodsForUser, arg.UserID, arg.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ListTasteProfileMoodsForUserRow
|
||||
for rows.Next() {
|
||||
var i ListTasteProfileMoodsForUserRow
|
||||
if err := rows.Scan(&i.Mood, &i.Weight); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listTasteProfileTagsForUser = `-- name: ListTasteProfileTagsForUser :many
|
||||
SELECT tag, weight
|
||||
FROM taste_profile_tags
|
||||
|
||||
@@ -110,9 +110,43 @@ func (q *Queries) ListPlayedTrackTagsForUser(ctx context.Context, arg ListPlayed
|
||||
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
|
||||
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
|
||||
@@ -131,6 +165,7 @@ type ListTracksMissingTagsRow struct {
|
||||
Mbid *string
|
||||
Title string
|
||||
ArtistName string
|
||||
ArtistMbid *string
|
||||
}
|
||||
|
||||
// Track-tag enrichment queries (milestone #160, #1490). The tag enricher
|
||||
@@ -140,7 +175,8 @@ type ListTracksMissingTagsRow struct {
|
||||
// Tracks eligible for tag enrichment: never processed (tag_source NULL)
|
||||
// or previously settled 'none' under an older provider version. Returns
|
||||
// the fields the provider chain needs — recording MBID (nullable) for
|
||||
// keyed lookups, plus title + artist name for name-based fallback.
|
||||
// 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)
|
||||
@@ -156,6 +192,7 @@ func (q *Queries) ListTracksMissingTags(ctx context.Context, arg ListTracksMissi
|
||||
&i.Mbid,
|
||||
&i.Title,
|
||||
&i.ArtistName,
|
||||
&i.ArtistMbid,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -28,8 +28,10 @@ LIMIT 1;
|
||||
-- found (with a stamped kind) in the user's live snapshot for that
|
||||
-- variant at ingestion time (#1249, generalized in #1270).
|
||||
INSERT INTO play_events (
|
||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text)
|
||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind,
|
||||
device_class
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text,
|
||||
sqlc.narg(device_class)::text)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSystemPickKindForTrack :one
|
||||
|
||||
@@ -10,8 +10,10 @@ SELECT
|
||||
(l.user_id IS NOT NULL)::bool AS is_liked,
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
@@ -40,7 +42,9 @@ WHERE t.id <> $2
|
||||
-- $10 taste_overlap K (#796 phase 2b — tracks by the user's top
|
||||
-- positively-weighted taste-profile artists, so taste-relevant tracks
|
||||
-- enter the pool even when the similarity/random arms miss them; scored
|
||||
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion).
|
||||
-- in Go via TasteMatch, so sim_score here is 0 pool-inclusion),
|
||||
-- $11 coplay_artists K (#1533 — tracks by artists co-played across the
|
||||
-- instance with the seed's artist; source='user_cooccurrence').
|
||||
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||
|
||||
WITH
|
||||
@@ -124,6 +128,22 @@ taste_overlap AS (
|
||||
ORDER BY tpa.weight DESC, t.id
|
||||
LIMIT $10
|
||||
),
|
||||
coplay_artists AS (
|
||||
-- Household co-play (#1533): tracks by artists co-played across the
|
||||
-- instance with the seed's artist (source='user_cooccurrence', built by
|
||||
-- the coplay worker). Mirrors similar_artists but from local co-occurrence
|
||||
-- instead of ListenBrainz; empty on single-user servers. Same 0.5 damp as
|
||||
-- similar_artists since it's artist-level.
|
||||
SELECT t.id AS track_id, asim.score * 0.5 AS sim_score
|
||||
FROM artist_similarity asim
|
||||
JOIN tracks t ON t.artist_id = asim.artist_b_id
|
||||
JOIN seed_artist sa ON asim.artist_a_id = sa.artist_id
|
||||
WHERE asim.source = 'user_cooccurrence'
|
||||
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||
AND t.id <> $2
|
||||
ORDER BY asim.score DESC, random()
|
||||
LIMIT $11
|
||||
),
|
||||
random_fill AS (
|
||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||
FROM tracks t
|
||||
@@ -135,6 +155,7 @@ random_fill AS (
|
||||
UNION SELECT track_id FROM tag_overlap
|
||||
UNION SELECT track_id FROM likes_overlap
|
||||
UNION SELECT track_id FROM taste_overlap
|
||||
UNION SELECT track_id FROM coplay_artists
|
||||
)
|
||||
ORDER BY random()
|
||||
LIMIT $9
|
||||
@@ -145,6 +166,7 @@ SELECT
|
||||
pe.last_played_at::timestamptz AS last_played_at,
|
||||
pe.play_count,
|
||||
pe.skip_count,
|
||||
al.release_date AS release_date,
|
||||
COALESCE(max(u.sim_score), 0.0) AS similarity_score
|
||||
FROM (
|
||||
SELECT track_id, sim_score FROM lb_similar
|
||||
@@ -152,9 +174,11 @@ FROM (
|
||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||
UNION ALL SELECT track_id, sim_score FROM coplay_artists
|
||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||
) u
|
||||
JOIN tracks t ON t.id = u.track_id
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
LEFT JOIN general_likes l ON l.user_id = $1 AND l.track_id = t.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT max(started_at) AS last_played_at,
|
||||
@@ -166,7 +190,72 @@ LEFT JOIN LATERAL (
|
||||
GROUP BY t.id, t.title, t.album_id, t.artist_id, t.duration_ms, t.file_path,
|
||||
t.file_format, t.file_size, t.bitrate, t.track_number, t.disc_number,
|
||||
t.mbid, t.genre, t.added_at, t.updated_at,
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count;
|
||||
l.user_id, pe.last_played_at, pe.play_count, pe.skip_count,
|
||||
al.release_date;
|
||||
|
||||
-- name: ListArtistContextPlayCountsForUser :many
|
||||
-- Per-artist completed-play counts split by whether each play falls in the
|
||||
-- CURRENT context cell, in the user's local timezone (#1531). The cell is
|
||||
-- daypart × weekday-type, optionally narrowed by device class (#1551): when
|
||||
-- $2 (the current device) is '', the device dimension is ignored (identical to
|
||||
-- the time-only #1531 behaviour, used by the daily mixes which have no device);
|
||||
-- when $2 is set (radio), a play only counts in the cell if its device_class
|
||||
-- matches. Feeds the context-affinity scoring term. Skips excluded; a 365-day
|
||||
-- window bounds cost. Daypart buckets: night [22,5) morning [5,12)
|
||||
-- afternoon [12,17) evening [17,22). Weekend = ISO days 6–7 (Sat/Sun).
|
||||
WITH tz AS (
|
||||
SELECT COALESCE(NULLIF(u.timezone, ''), 'UTC') AS zone
|
||||
FROM users u WHERE u.id = $1
|
||||
),
|
||||
now_cell AS (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 5 THEN 3
|
||||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 12 THEN 0
|
||||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 17 THEN 1
|
||||
WHEN EXTRACT(hour FROM now() AT TIME ZONE tz.zone) < 22 THEN 2
|
||||
ELSE 3
|
||||
END AS daypart,
|
||||
(EXTRACT(isodow FROM now() AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||||
FROM tz
|
||||
),
|
||||
plays AS (
|
||||
SELECT t.artist_id,
|
||||
pe.device_class,
|
||||
CASE
|
||||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 5 THEN 3
|
||||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 12 THEN 0
|
||||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 17 THEN 1
|
||||
WHEN EXTRACT(hour FROM pe.started_at AT TIME ZONE tz.zone) < 22 THEN 2
|
||||
ELSE 3
|
||||
END AS daypart,
|
||||
(EXTRACT(isodow FROM pe.started_at AT TIME ZONE tz.zone) >= 6) AS is_weekend
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
CROSS JOIN tz
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.was_skipped = false
|
||||
AND pe.started_at > now() - interval '365 days'
|
||||
)
|
||||
SELECT p.artist_id,
|
||||
count(*) AS total_plays,
|
||||
count(*) FILTER (
|
||||
WHERE p.daypart = (SELECT daypart FROM now_cell)
|
||||
AND p.is_weekend = (SELECT is_weekend FROM now_cell)
|
||||
AND ($2::text = '' OR p.device_class = $2::text)
|
||||
) AS cell_plays
|
||||
FROM plays p
|
||||
GROUP BY p.artist_id;
|
||||
|
||||
-- name: GetLatestPlayDeviceClassForUser :one
|
||||
-- The device_class of the user's most recent play (#1551), used as the
|
||||
-- "current device" for radio context conditioning. NULL when the latest play
|
||||
-- predates device capture or came from a client that didn't send one.
|
||||
SELECT device_class
|
||||
FROM play_events
|
||||
WHERE user_id = $1
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: SuggestArtistsForUser :many
|
||||
-- M5c: per-user artist suggestions ranked by signal x similarity. The
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
-- doesn't exist yet. Never overwrites operator-tuned values.
|
||||
INSERT INTO recommendation_weight_profiles (
|
||||
profile, base_weight, like_boost, recency_weight, skip_penalty,
|
||||
jitter_magnitude, context_weight, similarity_weight, taste_weight
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
jitter_magnitude, context_weight, similarity_weight, taste_weight,
|
||||
context_time_weight
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (profile) DO NOTHING;
|
||||
|
||||
-- name: ListWeightProfiles :many
|
||||
@@ -15,23 +16,25 @@ SELECT * FROM recommendation_weight_profiles ORDER BY profile;
|
||||
|
||||
-- name: UpdateWeightProfile :one
|
||||
UPDATE recommendation_weight_profiles
|
||||
SET base_weight = $2,
|
||||
like_boost = $3,
|
||||
recency_weight = $4,
|
||||
skip_penalty = $5,
|
||||
jitter_magnitude = $6,
|
||||
context_weight = $7,
|
||||
similarity_weight = $8,
|
||||
taste_weight = $9,
|
||||
updated_at = now()
|
||||
SET base_weight = $2,
|
||||
like_boost = $3,
|
||||
recency_weight = $4,
|
||||
skip_penalty = $5,
|
||||
jitter_magnitude = $6,
|
||||
context_weight = $7,
|
||||
similarity_weight = $8,
|
||||
taste_weight = $9,
|
||||
context_time_weight = $10,
|
||||
updated_at = now()
|
||||
WHERE profile = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpsertTasteTuningDefaults :exec
|
||||
INSERT INTO taste_tuning (
|
||||
singleton, half_life_days, engagement_hard_skip,
|
||||
engagement_neutral, engagement_full
|
||||
) VALUES (true, $1, $2, $3, $4)
|
||||
engagement_neutral, engagement_full, enriched_tag_scale, era_scale,
|
||||
mood_scale
|
||||
) VALUES (true, $1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (singleton) DO NOTHING;
|
||||
|
||||
-- name: GetTasteTuning :one
|
||||
@@ -43,6 +46,9 @@ UPDATE taste_tuning
|
||||
engagement_hard_skip = $2,
|
||||
engagement_neutral = $3,
|
||||
engagement_full = $4,
|
||||
enriched_tag_scale = $5,
|
||||
era_scale = $6,
|
||||
mood_scale = $7,
|
||||
updated_at = now()
|
||||
WHERE singleton = true
|
||||
RETURNING *;
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
-- One row per play in the decay-relevant window. completion is the play's
|
||||
-- completion ratio (precomputed column when present, else duration_played /
|
||||
-- track duration, clamped to [0,1]); age_days drives the time-decay. Genre
|
||||
-- is split into tags in Go. Quarantined tracks are excluded.
|
||||
-- is split into tags in Go; release_date derives the decade for the era
|
||||
-- facet (#1530). Quarantined tracks are excluded.
|
||||
SELECT
|
||||
t.id AS track_id,
|
||||
t.artist_id,
|
||||
t.genre,
|
||||
a.release_date,
|
||||
LEAST(GREATEST(
|
||||
COALESCE(pe.completion_ratio,
|
||||
pe.duration_played_ms::float8 / NULLIF(t.duration_ms, 0),
|
||||
@@ -18,6 +20,7 @@ SELECT
|
||||
(EXTRACT(epoch FROM now() - pe.started_at) / 86400.0)::float8 AS age_days
|
||||
FROM play_events pe
|
||||
JOIN tracks t ON t.id = pe.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
AND NOT EXISTS (
|
||||
@@ -26,12 +29,13 @@ WHERE pe.user_id = $1
|
||||
);
|
||||
|
||||
-- name: ListLikedTrackTasteInputsForUser :many
|
||||
-- (track_id, artist_id, genre) for each track the user has explicitly
|
||||
-- liked. Feeds the track-like bonus into the liked track's artist and
|
||||
-- tags; track_id keys the enriched track_tags lookup (#1490).
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre
|
||||
-- (track_id, artist_id, genre, release_date) for each track the user has
|
||||
-- explicitly liked. Feeds the track-like bonus into the liked track's artist,
|
||||
-- tags, and era (#1530); track_id keys the enriched track_tags lookup (#1490).
|
||||
SELECT t.id AS track_id, t.artist_id, t.genre, a.release_date
|
||||
FROM general_likes gl
|
||||
JOIN tracks t ON t.id = gl.track_id
|
||||
JOIN albums a ON a.id = t.album_id
|
||||
WHERE gl.user_id = $1;
|
||||
|
||||
-- name: ListLikedArtistIDsForUser :many
|
||||
@@ -66,3 +70,33 @@ FROM taste_profile_tags
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: DeleteTasteProfileErasForUser :exec
|
||||
DELETE FROM taste_profile_eras WHERE user_id = $1;
|
||||
|
||||
-- name: InsertTasteProfileEra :exec
|
||||
INSERT INTO taste_profile_eras (user_id, era, weight)
|
||||
VALUES ($1, $2, $3);
|
||||
|
||||
-- name: ListTasteProfileErasForUser :many
|
||||
-- Top-weighted taste eras (#1530); consumed by the scorer's era term.
|
||||
SELECT era, weight
|
||||
FROM taste_profile_eras
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2;
|
||||
|
||||
-- name: DeleteTasteProfileMoodsForUser :exec
|
||||
DELETE FROM taste_profile_moods WHERE user_id = $1;
|
||||
|
||||
-- name: InsertTasteProfileMood :exec
|
||||
INSERT INTO taste_profile_moods (user_id, mood, weight)
|
||||
VALUES ($1, $2, $3);
|
||||
|
||||
-- name: ListTasteProfileMoodsForUser :many
|
||||
-- Top-weighted taste moods (#1534); consumed by the scorer's mood term.
|
||||
SELECT mood, weight
|
||||
FROM taste_profile_moods
|
||||
WHERE user_id = $1
|
||||
ORDER BY weight DESC
|
||||
LIMIT $2;
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
-- Tracks eligible for tag enrichment: never processed (tag_source NULL)
|
||||
-- or previously settled 'none' under an older provider version. Returns
|
||||
-- the fields the provider chain needs — recording MBID (nullable) for
|
||||
-- keyed lookups, plus title + artist name for name-based fallback.
|
||||
-- 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
|
||||
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
|
||||
@@ -55,3 +56,11 @@ 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[]);
|
||||
|
||||
@@ -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,
|
||||
at time.Time,
|
||||
) (StartedResult, error) {
|
||||
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", at)
|
||||
return w.RecordPlayStartedWithSource(ctx, userID, trackID, clientID, "", "", at)
|
||||
}
|
||||
|
||||
// strPtrOrNil maps an empty string to a NULL text column (nullable pointer),
|
||||
// mirroring the clientID/source nil-on-empty pattern used above.
|
||||
func strPtrOrNil(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// systemPlaylistSources are the play_events.source values that count
|
||||
@@ -136,7 +145,7 @@ func lookupSystemPickKind(
|
||||
func (w *Writer) RecordPlayStartedWithSource(
|
||||
ctx context.Context,
|
||||
userID, trackID pgtype.UUID,
|
||||
clientID, source string,
|
||||
clientID, source, deviceClass string,
|
||||
at time.Time,
|
||||
) (StartedResult, error) {
|
||||
var out StartedResult
|
||||
@@ -165,13 +174,14 @@ func (w *Writer) RecordPlayStartedWithSource(
|
||||
}
|
||||
}
|
||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
DeviceClass: strPtrOrNil(deviceClass),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -389,7 +399,7 @@ func (w *Writer) RecordSyntheticCompletedPlay(
|
||||
func (w *Writer) RecordOfflinePlay(
|
||||
ctx context.Context,
|
||||
userID, trackID pgtype.UUID,
|
||||
clientID, source string,
|
||||
clientID, source, deviceClass string,
|
||||
at time.Time,
|
||||
durationPlayedMs int32,
|
||||
) error {
|
||||
@@ -441,13 +451,14 @@ func (w *Writer) RecordOfflinePlay(
|
||||
}
|
||||
}
|
||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
SessionID: sessionID,
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
DeviceClass: strPtrOrNil(deviceClass),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -226,10 +226,10 @@ func TestRecordOfflinePlay_DedupsByUserTrackStartedAt(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
ctx := context.Background()
|
||||
at := time.Now().UTC()
|
||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
|
||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", "", at, 120_000); err != nil {
|
||||
t.Fatalf("first offline: %v", err)
|
||||
}
|
||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", at, 120_000); err != nil {
|
||||
if err := f.w.RecordOfflinePlay(ctx, f.user, f.track, "c", "", "", at, 120_000); err != nil {
|
||||
t.Fatalf("replay offline: %v", err)
|
||||
}
|
||||
var count int
|
||||
@@ -444,7 +444,7 @@ func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
now := time.Now().UTC()
|
||||
if _, err := f.w.RecordPlayStartedWithSource(
|
||||
context.Background(), f.user, f.track, "c", "for_you", now,
|
||||
context.Background(), f.user, f.track, "c", "for_you", "", now,
|
||||
); err != nil {
|
||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||
}
|
||||
@@ -460,7 +460,7 @@ func TestRecordPlayStartedWithSource_AppendsRotation(t *testing.T) {
|
||||
|
||||
// Re-playing the same track stays a set (no duplicate append).
|
||||
if _, err := f.w.RecordPlayStartedWithSource(
|
||||
context.Background(), f.user, f.track, "c", "for_you", now.Add(time.Minute),
|
||||
context.Background(), f.user, f.track, "c", "for_you", "", now.Add(time.Minute),
|
||||
); err != nil {
|
||||
t.Fatalf("RecordPlayStartedWithSource 2: %v", err)
|
||||
}
|
||||
@@ -511,7 +511,7 @@ func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
seedSystemSnapshot(t, f, "for_you", "fresh")
|
||||
res, err := f.w.RecordPlayStartedWithSource(
|
||||
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
|
||||
context.Background(), f.user, f.track, "c", "for_you", "", time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||
@@ -530,7 +530,7 @@ func TestRecordPlayStartedWithSource_NoSnapshotMatch_PickKindNull(t *testing.T)
|
||||
// out, or no snapshot exists) stays unattributed rather than guessing.
|
||||
f := newFixture(t, 200_000)
|
||||
res, err := f.w.RecordPlayStartedWithSource(
|
||||
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
|
||||
context.Background(), f.user, f.track, "c", "for_you", "", time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||
@@ -552,7 +552,7 @@ func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||
res, err := f.w.RecordPlayStartedWithSource(
|
||||
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
|
||||
context.Background(), f.user, f.track, "c", "discover", "", time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||
@@ -572,7 +572,7 @@ func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
seedSystemSnapshot(t, f, "discover", "dormant")
|
||||
res, err := f.w.RecordPlayStartedWithSource(
|
||||
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
|
||||
context.Background(), f.user, f.track, "c", "discover", "", time.Now().UTC(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||
@@ -593,7 +593,7 @@ func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
|
||||
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||
at := time.Now().UTC().Add(-time.Hour)
|
||||
if err := f.w.RecordOfflinePlay(
|
||||
context.Background(), f.user, f.track, "c", "for_you", at, 180_000,
|
||||
context.Background(), f.user, f.track, "c", "for_you", "", at, 180_000,
|
||||
); err != nil {
|
||||
t.Fatalf("RecordOfflinePlay: %v", err)
|
||||
}
|
||||
|
||||
@@ -215,6 +215,9 @@ var (
|
||||
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
||||
// while passive avoidance (negative) gently demotes.
|
||||
TasteWeight: 1.5,
|
||||
// Time-of-day/weekday context affinity (#1531), in [-1,+1]. Starts
|
||||
// uniform with radio pending trend data.
|
||||
ContextTimeWeight: 1.0,
|
||||
}
|
||||
systemTasteConfig = taste.DefaultConfig()
|
||||
)
|
||||
@@ -543,6 +546,9 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
||||
// deep slice of the user's top taste-profile artists into the pool
|
||||
// (#796 phase 2b). Empty for cold-start users (no profile yet).
|
||||
TasteOverlap: 80,
|
||||
// Household co-play (#1533): a deeper collaborative slice for the
|
||||
// taste surfaces. Empty on single-user servers.
|
||||
UserCoplay: 40,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||
)
|
||||
|
||||
// LoadCandidates fetches the candidate pool for radio scoring. Combines
|
||||
@@ -41,6 +42,11 @@ func LoadCandidates(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
@@ -57,7 +63,11 @@ func LoadCandidates(
|
||||
PlayCount: int(r.PlayCount),
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
||||
// Fallback path: mood is scored only in the primary
|
||||
// (similarity) loader — loading per-candidate tags over this
|
||||
// near-whole-library pool isn't worth it (nil moods → 0).
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, nil),
|
||||
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -76,6 +86,10 @@ type CandidateSourceLimits struct {
|
||||
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
|
||||
// users have an empty profile, so it contributes nothing anyway).
|
||||
TasteOverlap int
|
||||
// UserCoplay (#1533): tracks by artists co-played across the instance
|
||||
// with the seed's artist (source='user_cooccurrence'). Empty on
|
||||
// single-user servers, so it contributes nothing there.
|
||||
UserCoplay int
|
||||
}
|
||||
|
||||
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
||||
@@ -87,6 +101,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
LikesOverlap: 20,
|
||||
RandomFill: 30,
|
||||
TasteOverlap: 20,
|
||||
UserCoplay: 20,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +134,7 @@ func LoadCandidatesFromSimilarity(
|
||||
Limit_4: int32(limits.LikesOverlap),
|
||||
Limit_5: int32(limits.RandomFill),
|
||||
Limit_6: int32(limits.TasteOverlap),
|
||||
Limit_7: int32(limits.UserCoplay),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -134,6 +150,20 @@ func LoadCandidatesFromSimilarity(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
trackIDs := make([]pgtype.UUID, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
trackIDs = append(trackIDs, r.Track.ID)
|
||||
}
|
||||
moods, err := loadCandidateMoods(ctx, q, trackIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]Candidate, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
var lpt *time.Time
|
||||
@@ -158,13 +188,42 @@ func LoadCandidatesFromSimilarity(
|
||||
SkipCount: int(r.SkipCount),
|
||||
ContextualMatchScore: ctxScore,
|
||||
SimilarityScore: simScore,
|
||||
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre),
|
||||
TasteMatchScore: profile.Match(
|
||||
r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, moods[r.Track.ID]),
|
||||
ContextAffinityScore: affinity.Affinity(r.Track.ArtistID),
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadCandidateMoods fetches the enriched tags for the given candidate tracks
|
||||
// and reduces each to its canonical mood buckets (internal/mood, #1534), so the
|
||||
// scorer can apply the mood facet per candidate. Tracks with no mood-word tags
|
||||
// are absent from the map (→ no mood signal). Empty input short-circuits.
|
||||
func loadCandidateMoods(
|
||||
ctx context.Context, q *dbq.Queries, trackIDs []pgtype.UUID,
|
||||
) (map[pgtype.UUID][]string, error) {
|
||||
if len(trackIDs) == 0 {
|
||||
return map[pgtype.UUID][]string{}, nil
|
||||
}
|
||||
rows, err := q.ListTrackTagsForTracks(ctx, trackIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tagsByTrack := make(map[pgtype.UUID][]string)
|
||||
for _, r := range rows {
|
||||
tagsByTrack[r.TrackID] = append(tagsByTrack[r.TrackID], r.Tag)
|
||||
}
|
||||
out := make(map[pgtype.UUID][]string, len(tagsByTrack))
|
||||
for id, tags := range tagsByTrack {
|
||||
if m := mood.Of(tags); len(m) > 0 {
|
||||
out[id] = m
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadContextualLikesByTrack fetches the user's active contextual_likes in
|
||||
// one query and groups them by track_id. Rows whose session_vector fails
|
||||
// to unmarshal are skipped with no error (don't poison scoring over one
|
||||
|
||||
@@ -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
|
||||
// profile signal (cold start / artist+tags absent from the profile).
|
||||
TasteMatchScore float64
|
||||
// ContextAffinityScore is the candidate artist's time-of-day/weekday
|
||||
// affinity for the CURRENT context (#1531), in [-1, +1]: positive when the
|
||||
// artist's plays concentrate in the current daypart × weekday-type cell
|
||||
// more than the user's baseline, negative when under-represented, 0 when
|
||||
// there's no history (cold-start neutral).
|
||||
ContextAffinityScore float64
|
||||
}
|
||||
|
||||
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||
// config.RecommendationConfig and are propagated here per request.
|
||||
type ScoringWeights struct {
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
RecencyWeight float64
|
||||
SkipPenalty float64
|
||||
JitterMagnitude float64
|
||||
ContextWeight float64
|
||||
SimilarityWeight float64
|
||||
TasteWeight float64
|
||||
ContextTimeWeight float64
|
||||
}
|
||||
|
||||
// Score computes the weighted-shuffle score per spec §6:
|
||||
@@ -48,6 +55,7 @@ type ScoringWeights struct {
|
||||
// + contextual_match_score * ContextWeight
|
||||
// + similarity_score * SimilarityWeight
|
||||
// + taste_match_score * TasteWeight
|
||||
// + context_affinity_score * ContextTimeWeight
|
||||
// + small_random_jitter
|
||||
//
|
||||
// Higher score = more likely to surface. rng is a function returning a
|
||||
@@ -63,6 +71,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64
|
||||
s += in.ContextualMatchScore * w.ContextWeight
|
||||
s += in.SimilarityScore * w.SimilarityWeight
|
||||
s += in.TasteMatchScore * w.TasteWeight
|
||||
s += in.ContextAffinityScore * w.ContextTimeWeight
|
||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ type SessionVector struct {
|
||||
Artists []string `json:"artists"`
|
||||
Tags map[string]int `json:"tags"`
|
||||
RecentTrackIDs []string `json:"recent_track_ids"`
|
||||
// DeviceClass is the current request's device (#1551), set by the radio
|
||||
// handler from the user's latest play; drives the device dimension of the
|
||||
// context-affinity term. Empty (omitted) for the daily mixes and for the
|
||||
// snapshot stored on play_events, so it never narrows those.
|
||||
DeviceClass string `json:"device_class,omitempty"`
|
||||
}
|
||||
|
||||
func BuildSessionVector(priorTracks []dbq.Track) SessionVector {
|
||||
|
||||
@@ -2,6 +2,7 @@ package recommendation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -10,24 +11,37 @@ import (
|
||||
)
|
||||
|
||||
// Taste-match tuning. The taste profile (written by internal/taste) holds
|
||||
// signed, unbounded artist/tag weights; these scales squash them into a
|
||||
// signed, unbounded artist/tag/era weights; these scales squash them into a
|
||||
// bounded [-1, +1] match via tanh, so one outlier artist can't compress the
|
||||
// rest toward zero (as a per-user max-normalisation would). A weight at the
|
||||
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
|
||||
//
|
||||
// The era (#1530) and mood (#1534) terms are added on TOP of artist+tag
|
||||
// (shares don't sum to 1) so they're pure nudges that never weaken the existing
|
||||
// artist/tag signal when a track lacks a date or mood tags; clampUnit bounds
|
||||
// the combined result. Their shares are small — both are coarse, partial-
|
||||
// coverage signals — and re-bakeable if the lab shows they should carry more.
|
||||
const (
|
||||
tasteArtistScale = 4.0
|
||||
tasteTagScale = 3.0
|
||||
tasteEraScale = 4.0
|
||||
tasteMoodScale = 3.0
|
||||
tasteArtistShare = 0.7
|
||||
tasteTagShare = 0.3
|
||||
tasteEraShare = 0.15
|
||||
tasteMoodShare = 0.12
|
||||
tasteProfileLimit = 2000 // read cap; profiles are size-capped on write
|
||||
)
|
||||
|
||||
// TasteProfile is the read-side view of a user's learned taste: signed
|
||||
// weights over artists and genre tags. The zero value (and any unknown
|
||||
// artist/tag) contributes 0, so cold-start users get no taste effect.
|
||||
// TasteProfile is the read-side view of a user's learned taste: signed weights
|
||||
// over artists, genre tags, decade/era buckets, and mood buckets. The zero
|
||||
// value (and any unknown key) contributes 0, so cold-start users get no taste
|
||||
// effect.
|
||||
type TasteProfile struct {
|
||||
artists map[pgtype.UUID]float64
|
||||
tags map[string]float64
|
||||
eras map[string]float64
|
||||
moods map[string]float64
|
||||
}
|
||||
|
||||
// LoadTasteProfile reads the user's taste profile from the taste_profile_*
|
||||
@@ -46,9 +60,23 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
eras, err := q.ListTasteProfileErasForUser(ctx, dbq.ListTasteProfileErasForUserParams{
|
||||
UserID: userID, Limit: tasteProfileLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
moods, err := q.ListTasteProfileMoodsForUser(ctx, dbq.ListTasteProfileMoodsForUserParams{
|
||||
UserID: userID, Limit: tasteProfileLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return TasteProfile{}, err
|
||||
}
|
||||
p := TasteProfile{
|
||||
artists: make(map[pgtype.UUID]float64, len(arts)),
|
||||
tags: make(map[string]float64, len(tags)),
|
||||
eras: make(map[string]float64, len(eras)),
|
||||
moods: make(map[string]float64, len(moods)),
|
||||
}
|
||||
for _, a := range arts {
|
||||
p.artists[a.ArtistID] = a.Weight
|
||||
@@ -56,13 +84,23 @@ func LoadTasteProfile(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (
|
||||
for _, t := range tags {
|
||||
p.tags[t.Tag] = t.Weight
|
||||
}
|
||||
for _, e := range eras {
|
||||
p.eras[e.Era] = e.Weight
|
||||
}
|
||||
for _, m := range moods {
|
||||
p.moods[m.Mood] = m.Weight
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Match scores a candidate track's fit to the profile in [-1, +1]: a blend of
|
||||
// the artist's taste weight and the average of its genre tags' weights, each
|
||||
// tanh-squashed. Absent artist/tags contribute 0.
|
||||
func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
|
||||
// the artist's taste weight, the average of its genre tags' weights, its
|
||||
// decade/era weight, and the average of its mood buckets' weights, each
|
||||
// tanh-squashed. moods are the candidate's canonical mood buckets (from
|
||||
// internal/mood; nil when unknown). Absent artist/tags/era/moods contribute 0.
|
||||
func (p TasteProfile) Match(
|
||||
artistID pgtype.UUID, genre *string, releaseDate pgtype.Date, moods []string,
|
||||
) float64 {
|
||||
a := math.Tanh(p.artists[artistID] / tasteArtistScale)
|
||||
|
||||
var tg float64
|
||||
@@ -76,7 +114,31 @@ func (p TasteProfile) Match(artistID pgtype.UUID, genre *string) float64 {
|
||||
tg = math.Tanh((sum / float64(len(tags))) / tasteTagScale)
|
||||
}
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg)
|
||||
|
||||
var er float64
|
||||
if decade := decadeOf(releaseDate); decade != "" {
|
||||
er = math.Tanh(p.eras[decade] / tasteEraScale)
|
||||
}
|
||||
|
||||
var mo float64
|
||||
if len(moods) > 0 {
|
||||
var sum float64
|
||||
for _, m := range moods {
|
||||
sum += p.moods[m]
|
||||
}
|
||||
mo = math.Tanh((sum / float64(len(moods))) / tasteMoodScale)
|
||||
}
|
||||
return clampUnit(tasteArtistShare*a + tasteTagShare*tg + tasteEraShare*er + tasteMoodShare*mo)
|
||||
}
|
||||
|
||||
// decadeOf maps an album release date to a decade bucket ("1990s"), or "" for
|
||||
// an absent date. Mirrors the taste builder's helper (kept local to avoid a
|
||||
// cross-package dependency) so a candidate's era is derived exactly as learned.
|
||||
func decadeOf(d pgtype.Date) string {
|
||||
if !d.Valid {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
|
||||
}
|
||||
|
||||
// clampUnit constrains x to [-1, 1].
|
||||
|
||||
@@ -17,6 +17,14 @@ func uuidN(n byte) pgtype.UUID {
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
// noDate is an absent release date — the era term contributes 0.
|
||||
func noDate() pgtype.Date { return pgtype.Date{} }
|
||||
|
||||
// dateInYear builds a valid release date in the given year (era = its decade).
|
||||
func dateInYear(year int) pgtype.Date {
|
||||
return pgtype.Date{Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC), Valid: true}
|
||||
}
|
||||
|
||||
func TestTasteProfile_Match(t *testing.T) {
|
||||
loved := uuidN(1)
|
||||
disliked := uuidN(2)
|
||||
@@ -26,32 +34,76 @@ func TestTasteProfile_Match(t *testing.T) {
|
||||
tags: map[string]float64{"Jazz": 6.0, "Noise": -6.0},
|
||||
}
|
||||
|
||||
if m := p.Match(loved, strPtr("Jazz")); m <= 0.5 {
|
||||
if m := p.Match(loved, strPtr("Jazz"), noDate(), nil); m <= 0.5 {
|
||||
t.Errorf("loved artist + loved tag = %.3f, want strongly positive", m)
|
||||
}
|
||||
if m := p.Match(disliked, strPtr("Noise")); m >= -0.5 {
|
||||
if m := p.Match(disliked, strPtr("Noise"), noDate(), nil); m >= -0.5 {
|
||||
t.Errorf("disliked artist + disliked tag = %.3f, want strongly negative", m)
|
||||
}
|
||||
if m := p.Match(unknown, nil); m != 0 {
|
||||
if m := p.Match(unknown, nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("unknown artist, no genre = %.3f, want 0", m)
|
||||
}
|
||||
// Artist dominates (0.7 share): loved artist with an unknown tag is still
|
||||
// clearly positive.
|
||||
if m := p.Match(loved, strPtr("Unheard")); m <= 0 {
|
||||
if m := p.Match(loved, strPtr("Unheard"), noDate(), nil); m <= 0 {
|
||||
t.Errorf("loved artist + unknown tag = %.3f, want positive", m)
|
||||
}
|
||||
// Output stays within [-1, 1] even with saturated inputs.
|
||||
for _, a := range []pgtype.UUID{loved, disliked, unknown} {
|
||||
m := p.Match(a, strPtr("Jazz"))
|
||||
m := p.Match(a, strPtr("Jazz"), dateInYear(1994), nil)
|
||||
if m < -1 || m > 1 {
|
||||
t.Errorf("Match out of [-1,1]: %.3f", m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTasteProfile_EraTerm verifies the decade facet nudges the match: with
|
||||
// artist + genre held neutral, a loved era lifts the score and a disliked era
|
||||
// lowers it, while an undated track is unaffected.
|
||||
func TestTasteProfile_EraTerm(t *testing.T) {
|
||||
art := uuidN(1)
|
||||
p := TasteProfile{
|
||||
artists: map[pgtype.UUID]float64{},
|
||||
tags: map[string]float64{},
|
||||
eras: map[string]float64{"1990s": 8.0, "1980s": -8.0},
|
||||
}
|
||||
loved := p.Match(art, nil, dateInYear(1994), nil)
|
||||
if loved <= 0 {
|
||||
t.Errorf("loved era (1990s) = %.3f, want positive", loved)
|
||||
}
|
||||
disliked := p.Match(art, nil, dateInYear(1987), nil)
|
||||
if disliked >= 0 {
|
||||
t.Errorf("disliked era (1980s) = %.3f, want negative", disliked)
|
||||
}
|
||||
if m := p.Match(art, nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("undated track = %.3f, want 0 (no era contribution)", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTasteProfile_MoodTerm verifies the mood facet nudges the match: with
|
||||
// artist/genre/era neutral, a loved mood lifts the score and a disliked mood
|
||||
// lowers it, while a track with no mood buckets is unaffected.
|
||||
func TestTasteProfile_MoodTerm(t *testing.T) {
|
||||
art := uuidN(1)
|
||||
p := TasteProfile{
|
||||
artists: map[pgtype.UUID]float64{},
|
||||
tags: map[string]float64{},
|
||||
moods: map[string]float64{"chill": 8.0, "aggressive": -8.0},
|
||||
}
|
||||
if m := p.Match(art, nil, noDate(), []string{"chill"}); m <= 0 {
|
||||
t.Errorf("loved mood (chill) = %.3f, want positive", m)
|
||||
}
|
||||
if m := p.Match(art, nil, noDate(), []string{"aggressive"}); m >= 0 {
|
||||
t.Errorf("disliked mood (aggressive) = %.3f, want negative", m)
|
||||
}
|
||||
if m := p.Match(art, nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("no moods = %.3f, want 0 (no mood contribution)", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasteProfile_EmptyIsNeutral(t *testing.T) {
|
||||
var p TasteProfile // zero value: nil maps
|
||||
if m := p.Match(uuidN(1), strPtr("Jazz")); m != 0 {
|
||||
if m := p.Match(uuidN(1), strPtr("Jazz"), dateInYear(1994), nil); m != 0 {
|
||||
t.Errorf("empty profile Match = %.3f, want 0 (cold start neutral)", m)
|
||||
}
|
||||
}
|
||||
@@ -100,10 +152,10 @@ func TestLoadTasteProfile_RoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if m := p.Match(art.ID, strPtr("Jazz")); m <= 0.5 {
|
||||
if m := p.Match(art.ID, strPtr("Jazz"), noDate(), nil); m <= 0.5 {
|
||||
t.Errorf("round-trip Match = %.3f, want strongly positive", m)
|
||||
}
|
||||
if m := p.Match(uuidN(9), nil); m != 0 {
|
||||
if m := p.Match(uuidN(9), nil, noDate(), nil); m != 0 {
|
||||
t.Errorf("absent artist Match = %.3f, want 0", m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,10 @@ var weightFields = map[string]weightField{
|
||||
get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
|
||||
set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v },
|
||||
},
|
||||
"context_time_weight": {
|
||||
get: func(w recommendation.ScoringWeights) float64 { return w.ContextTimeWeight },
|
||||
set: func(w *recommendation.ScoringWeights, v float64) { w.ContextTimeWeight = v },
|
||||
},
|
||||
}
|
||||
|
||||
// applyWeightPatch validates and applies a partial update, returning
|
||||
@@ -129,6 +133,12 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
|
||||
target = &next.EngagementNeutral
|
||||
case "engagement_full":
|
||||
target = &next.EngagementFull
|
||||
case "enriched_tag_scale":
|
||||
target = &next.EnrichedTagScale
|
||||
case "era_scale":
|
||||
target = &next.EraScale
|
||||
case "mood_scale":
|
||||
target = &next.MoodScale
|
||||
default:
|
||||
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||
}
|
||||
@@ -174,5 +184,8 @@ func diffTaste(a, b TasteTuning) []fieldChange {
|
||||
add("engagement_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
|
||||
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
|
||||
add("engagement_full", a.EngagementFull, b.EngagementFull)
|
||||
add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
|
||||
add("era_scale", a.EraScale, b.EraScale)
|
||||
add("mood_scale", a.MoodScale, b.MoodScale)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -38,28 +38,36 @@ const (
|
||||
)
|
||||
|
||||
// TasteTuning is the tunable subset of taste.Config: the engagement
|
||||
// half-life and the completion→engagement curve points.
|
||||
// half-life, the completion→engagement curve points, the enriched-tag
|
||||
// weight (how much folksonomy tags count vs raw ID3 genre, #1520), and the
|
||||
// era-facet weight (how strongly a decade-play imprints, #1530).
|
||||
type TasteTuning struct {
|
||||
HalfLifeDays float64
|
||||
EngagementHardSkip float64
|
||||
EngagementNeutral float64
|
||||
EngagementFull float64
|
||||
EnrichedTagScale float64
|
||||
EraScale float64
|
||||
MoodScale float64
|
||||
}
|
||||
|
||||
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
||||
// here from config.RecommendationConfig — YAML is bootstrap-only,
|
||||
// rule: config in UI). Radio is seed-directed (the user picked a
|
||||
// direction), so taste is a lighter nudge than in the daily mixes.
|
||||
// ContextTimeWeight starts uniform (1.0) across both profiles pending
|
||||
// trend data (#1531); split them once the metrics view justifies it.
|
||||
func ShippedRadioWeights() recommendation.ScoringWeights {
|
||||
return recommendation.ScoringWeights{
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 1.0,
|
||||
JitterMagnitude: 0.1,
|
||||
ContextWeight: 2.0,
|
||||
SimilarityWeight: 2.0,
|
||||
TasteWeight: 1.0,
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 1.0,
|
||||
JitterMagnitude: 0.1,
|
||||
ContextWeight: 2.0,
|
||||
SimilarityWeight: 2.0,
|
||||
TasteWeight: 1.0,
|
||||
ContextTimeWeight: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,14 +75,15 @@ func ShippedRadioWeights() recommendation.ScoringWeights {
|
||||
// Must stay in sync with the pre-push literal in playlists/system.go.
|
||||
func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
||||
return recommendation.ScoringWeights{
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 2.0,
|
||||
JitterMagnitude: 0.1,
|
||||
ContextWeight: 0.5,
|
||||
SimilarityWeight: 1.5,
|
||||
TasteWeight: 1.5,
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 2.0,
|
||||
JitterMagnitude: 0.1,
|
||||
ContextWeight: 0.5,
|
||||
SimilarityWeight: 1.5,
|
||||
TasteWeight: 1.5,
|
||||
ContextTimeWeight: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +95,9 @@ func ShippedTasteTuning() TasteTuning {
|
||||
EngagementHardSkip: d.Engagement.HardSkip,
|
||||
EngagementNeutral: d.Engagement.NeutralCompletion,
|
||||
EngagementFull: d.Engagement.FullCompletion,
|
||||
EnrichedTagScale: d.EnrichedTagScale,
|
||||
EraScale: d.EraScale,
|
||||
MoodScale: d.MoodScale,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +145,9 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
EngagementHardSkip: st.EngagementHardSkip,
|
||||
EngagementNeutral: st.EngagementNeutral,
|
||||
EngagementFull: st.EngagementFull,
|
||||
EnrichedTagScale: st.EnrichedTagScale,
|
||||
EraScale: st.EraScale,
|
||||
MoodScale: st.MoodScale,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("seed taste tuning: %w", err)
|
||||
}
|
||||
@@ -156,6 +171,9 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
EngagementHardSkip: tt.EngagementHardSkip,
|
||||
EngagementNeutral: tt.EngagementNeutral,
|
||||
EngagementFull: tt.EngagementFull,
|
||||
EnrichedTagScale: tt.EnrichedTagScale,
|
||||
EraScale: tt.EraScale,
|
||||
MoodScale: tt.MoodScale,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -205,6 +223,9 @@ func (s *Service) TasteConfig() taste.Config {
|
||||
NeutralCompletion: t.EngagementNeutral,
|
||||
FullCompletion: t.EngagementFull,
|
||||
}
|
||||
cfg.EnrichedTagScale = t.EnrichedTagScale
|
||||
cfg.EraScale = t.EraScale
|
||||
cfg.MoodScale = t.MoodScale
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -301,6 +322,9 @@ func (s *Service) persistTaste(
|
||||
EngagementHardSkip: t.EngagementHardSkip,
|
||||
EngagementNeutral: t.EngagementNeutral,
|
||||
EngagementFull: t.EngagementFull,
|
||||
EnrichedTagScale: t.EnrichedTagScale,
|
||||
EraScale: t.EraScale,
|
||||
MoodScale: t.MoodScale,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("update taste tuning: %w", err)
|
||||
}
|
||||
@@ -334,41 +358,44 @@ func (s *Service) audit(
|
||||
|
||||
func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
|
||||
return dbq.UpsertWeightProfileDefaultsParams{
|
||||
Profile: profile,
|
||||
BaseWeight: w.BaseWeight,
|
||||
LikeBoost: w.LikeBoost,
|
||||
RecencyWeight: w.RecencyWeight,
|
||||
SkipPenalty: w.SkipPenalty,
|
||||
JitterMagnitude: w.JitterMagnitude,
|
||||
ContextWeight: w.ContextWeight,
|
||||
SimilarityWeight: w.SimilarityWeight,
|
||||
TasteWeight: w.TasteWeight,
|
||||
Profile: profile,
|
||||
BaseWeight: w.BaseWeight,
|
||||
LikeBoost: w.LikeBoost,
|
||||
RecencyWeight: w.RecencyWeight,
|
||||
SkipPenalty: w.SkipPenalty,
|
||||
JitterMagnitude: w.JitterMagnitude,
|
||||
ContextWeight: w.ContextWeight,
|
||||
SimilarityWeight: w.SimilarityWeight,
|
||||
TasteWeight: w.TasteWeight,
|
||||
ContextTimeWeight: w.ContextTimeWeight,
|
||||
}
|
||||
}
|
||||
|
||||
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
|
||||
return dbq.UpdateWeightProfileParams{
|
||||
Profile: profile,
|
||||
BaseWeight: w.BaseWeight,
|
||||
LikeBoost: w.LikeBoost,
|
||||
RecencyWeight: w.RecencyWeight,
|
||||
SkipPenalty: w.SkipPenalty,
|
||||
JitterMagnitude: w.JitterMagnitude,
|
||||
ContextWeight: w.ContextWeight,
|
||||
SimilarityWeight: w.SimilarityWeight,
|
||||
TasteWeight: w.TasteWeight,
|
||||
Profile: profile,
|
||||
BaseWeight: w.BaseWeight,
|
||||
LikeBoost: w.LikeBoost,
|
||||
RecencyWeight: w.RecencyWeight,
|
||||
SkipPenalty: w.SkipPenalty,
|
||||
JitterMagnitude: w.JitterMagnitude,
|
||||
ContextWeight: w.ContextWeight,
|
||||
SimilarityWeight: w.SimilarityWeight,
|
||||
TasteWeight: w.TasteWeight,
|
||||
ContextTimeWeight: w.ContextTimeWeight,
|
||||
}
|
||||
}
|
||||
|
||||
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
|
||||
return recommendation.ScoringWeights{
|
||||
BaseWeight: r.BaseWeight,
|
||||
LikeBoost: r.LikeBoost,
|
||||
RecencyWeight: r.RecencyWeight,
|
||||
SkipPenalty: r.SkipPenalty,
|
||||
JitterMagnitude: r.JitterMagnitude,
|
||||
ContextWeight: r.ContextWeight,
|
||||
SimilarityWeight: r.SimilarityWeight,
|
||||
TasteWeight: r.TasteWeight,
|
||||
BaseWeight: r.BaseWeight,
|
||||
LikeBoost: r.LikeBoost,
|
||||
RecencyWeight: r.RecencyWeight,
|
||||
SkipPenalty: r.SkipPenalty,
|
||||
JitterMagnitude: r.JitterMagnitude,
|
||||
ContextWeight: r.ContextWeight,
|
||||
SimilarityWeight: r.SimilarityWeight,
|
||||
TasteWeight: r.TasteWeight,
|
||||
ContextTimeWeight: r.ContextTimeWeight,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,67 @@ func TestUpdateTaste_CurveOrderingEnforced(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTaste_EnrichedTagScale(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
// In-range update persists into cache + the assembled taste config.
|
||||
if err := s.UpdateTaste(context.Background(),
|
||||
map[string]float64{"enriched_tag_scale": 0.8}); err != nil {
|
||||
t.Fatalf("UpdateTaste: %v", err)
|
||||
}
|
||||
if got := s.Taste().EnrichedTagScale; got != 0.8 {
|
||||
t.Errorf("Taste().EnrichedTagScale = %v, want 0.8", got)
|
||||
}
|
||||
if got := s.TasteConfig().EnrichedTagScale; got != 0.8 {
|
||||
t.Errorf("TasteConfig().EnrichedTagScale = %v, want 0.8", got)
|
||||
}
|
||||
// Out of [0,1] rejects.
|
||||
if err := s.UpdateTaste(context.Background(),
|
||||
map[string]float64{"enriched_tag_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
|
||||
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTaste_EraScale(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
// In-range update persists into cache + the assembled taste config.
|
||||
if err := s.UpdateTaste(context.Background(),
|
||||
map[string]float64{"era_scale": 0.8}); err != nil {
|
||||
t.Fatalf("UpdateTaste: %v", err)
|
||||
}
|
||||
if got := s.Taste().EraScale; got != 0.8 {
|
||||
t.Errorf("Taste().EraScale = %v, want 0.8", got)
|
||||
}
|
||||
if got := s.TasteConfig().EraScale; got != 0.8 {
|
||||
t.Errorf("TasteConfig().EraScale = %v, want 0.8", got)
|
||||
}
|
||||
// Out of [0,1] rejects.
|
||||
if err := s.UpdateTaste(context.Background(),
|
||||
map[string]float64{"era_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
|
||||
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTaste_MoodScale(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
if err := s.UpdateTaste(context.Background(),
|
||||
map[string]float64{"mood_scale": 0.8}); err != nil {
|
||||
t.Fatalf("UpdateTaste: %v", err)
|
||||
}
|
||||
if got := s.Taste().MoodScale; got != 0.8 {
|
||||
t.Errorf("Taste().MoodScale = %v, want 0.8", got)
|
||||
}
|
||||
if got := s.TasteConfig().MoodScale; got != 0.8 {
|
||||
t.Errorf("TasteConfig().MoodScale = %v, want 0.8", got)
|
||||
}
|
||||
if err := s.UpdateTaste(context.Background(),
|
||||
map[string]float64{"mood_scale": 1.5}); !errors.Is(err, ErrOutOfRange) {
|
||||
t.Errorf("err = %v, want ErrOutOfRange for 1.5", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset_RestoresShippedAndAudits(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
|
||||
@@ -171,6 +171,9 @@ func (e *Enricher) EnrichTrackBatch(ctx context.Context, limit int,
|
||||
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)
|
||||
|
||||
@@ -32,11 +32,14 @@ type Tag struct {
|
||||
|
||||
// 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).
|
||||
// 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
|
||||
|
||||
@@ -17,13 +17,18 @@ const (
|
||||
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 recording-level folksonomy tags from the
|
||||
// MusicBrainz web service. Keyless (the public endpoint needs no auth) and
|
||||
// on by default — the always-available baseline source. MBID-only: a track
|
||||
// with no recording MBID yields ErrNotFound (name search is ambiguous for
|
||||
// recordings and deferred to name-based providers like Last.fm).
|
||||
// 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
|
||||
@@ -61,24 +66,54 @@ type mbTag struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// FetchTrackTags looks up the recording's tags by MBID. Returns ErrNotFound
|
||||
// when disabled, when the track has no MBID, or when MusicBrainz has no tags.
|
||||
// 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() || ref.MBID == "" {
|
||||
if !p.enabled.Load() {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
q := url.Values{"inc": {"tags"}, "fmt": {"json"}}
|
||||
full := mbBaseURL + "/recording/" + url.PathEscape(ref.MBID) + "?" + q.Encode()
|
||||
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
|
||||
}
|
||||
out := normalizeMBTags(resp.Tags)
|
||||
if len(out) == 0 {
|
||||
return nil, ErrNotFound
|
||||
tags := normalizeMBTags(resp.Tags)
|
||||
if scale != 1.0 {
|
||||
for i := range tags {
|
||||
tags[i].Weight *= scale
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
// TestConnection issues a tiny search request; any non-transient response
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -50,13 +51,98 @@ func TestMusicBrainzFetch_GatedOff(t *testing.T) {
|
||||
TrackRef{MBID: "abc"}); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("disabled: err = %v, want ErrNotFound", err)
|
||||
}
|
||||
// No MBID → ErrNotFound (MBID-only provider).
|
||||
// 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"}]}`))
|
||||
|
||||
+118
-17
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/mood"
|
||||
)
|
||||
|
||||
// Config holds the operator-tunable knobs. DefaultConfig supplies tuned
|
||||
@@ -46,6 +47,21 @@ type Config struct {
|
||||
// contribution entirely (falls back to genre-only).
|
||||
EnrichedTagScale float64
|
||||
|
||||
// EraScale weights the decade/era facet (#1530): each play adds
|
||||
// base × this scale to its album's decade bucket ("1990s"), where base
|
||||
// is the decayed engagement (or the tag-like bonus for a liked track).
|
||||
// Era is a coarse signal, so it's kept ≤ 1 to imprint at most as strongly
|
||||
// as the raw engagement; 0 disables the era facet entirely. Tunable in the
|
||||
// lab (mirrors EnrichedTagScale, #1520).
|
||||
EraScale float64
|
||||
|
||||
// MoodScale weights the mood facet (#1534): each play adds base × this
|
||||
// scale to the canonical mood buckets (melancholic / chill / …) derived
|
||||
// from its enriched folksonomy tags via internal/mood. Coverage is partial
|
||||
// (depends on tag enrichment), so it's a supplement kept ≤ 1; 0 disables
|
||||
// the mood facet entirely. Tunable in the lab (mirrors EraScale).
|
||||
MoodScale float64
|
||||
|
||||
// ArtistFloor / TagFloor clamp how negative a single entity's weight may
|
||||
// go. Aggregation already protects an artist the user likes (one skip
|
||||
// nets out against many good plays); the floor additionally bounds the
|
||||
@@ -58,11 +74,17 @@ type Config struct {
|
||||
WeightEpsilon float64
|
||||
|
||||
// Size caps (guard rails for pathological libraries). Persist the top
|
||||
// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags.
|
||||
// MaxArtists by weight plus the most-negative MaxNegArtists; same for tags
|
||||
// and eras. Real libraries span ~a dozen decades, so the era caps rarely
|
||||
// bind — they exist for symmetry with the other facets.
|
||||
MaxArtists int
|
||||
MaxNegArtists int
|
||||
MaxTags int
|
||||
MaxNegTags int
|
||||
MaxEras int
|
||||
MaxNegEras int
|
||||
MaxMoods int
|
||||
MaxNegMoods int
|
||||
}
|
||||
|
||||
// DefaultConfig returns the tuned starting configuration.
|
||||
@@ -75,6 +97,8 @@ func DefaultConfig() Config {
|
||||
TrackLikeBonus: 1.0,
|
||||
TagLikeBonus: 0.5,
|
||||
EnrichedTagScale: 0.5,
|
||||
EraScale: 0.5,
|
||||
MoodScale: 0.5,
|
||||
ArtistFloor: -3.0,
|
||||
TagFloor: -3.0,
|
||||
WeightEpsilon: 0.05,
|
||||
@@ -82,6 +106,10 @@ func DefaultConfig() Config {
|
||||
MaxNegArtists: 150,
|
||||
MaxTags: 200,
|
||||
MaxNegTags: 60,
|
||||
MaxEras: 30,
|
||||
MaxNegEras: 15,
|
||||
MaxMoods: 20,
|
||||
MaxNegMoods: 10,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,36 +129,42 @@ func BuildTasteProfile(
|
||||
) error {
|
||||
q := dbq.New(pool)
|
||||
|
||||
artistW, tagW, err := accumulate(ctx, q, userID, cfg)
|
||||
artistW, tagW, eraW, moodW, err := accumulate(ctx, q, userID, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
applyFloor(artistW, cfg.ArtistFloor)
|
||||
applyFloor(tagW, cfg.TagFloor)
|
||||
// Era + mood share the tag floor (all text-keyed facets; a decade or mood
|
||||
// that's been heavily skipped shouldn't dominate as an extreme negative).
|
||||
applyFloor(eraW, cfg.TagFloor)
|
||||
applyFloor(moodW, cfg.TagFloor)
|
||||
|
||||
strLess := func(a, b string) bool { return a < b }
|
||||
artists := rankWeighted(artistW, cfg.WeightEpsilon, cfg.MaxArtists, cfg.MaxNegArtists, uuidLess)
|
||||
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags,
|
||||
func(a, b string) bool { return a < b })
|
||||
tags := rankWeighted(tagW, cfg.WeightEpsilon, cfg.MaxTags, cfg.MaxNegTags, strLess)
|
||||
eras := rankWeighted(eraW, cfg.WeightEpsilon, cfg.MaxEras, cfg.MaxNegEras, strLess)
|
||||
moods := rankWeighted(moodW, cfg.WeightEpsilon, cfg.MaxMoods, cfg.MaxNegMoods, strLess)
|
||||
|
||||
if err := persist(ctx, pool, userID, artists, tags); err != nil {
|
||||
if err := persist(ctx, pool, userID, artists, tags, eras, moods); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Debug("taste: profile rebuilt",
|
||||
"user_id", uuidString(userID), "artists", len(artists), "tags", len(tags))
|
||||
logger.Debug("taste: profile rebuilt", "user_id", uuidString(userID),
|
||||
"artists", len(artists), "tags", len(tags), "eras", len(eras), "moods", len(moods))
|
||||
return nil
|
||||
}
|
||||
|
||||
// accumulate sums decayed play engagement and like bonuses into artist/tag
|
||||
// weight maps.
|
||||
// accumulate sums decayed play engagement and like bonuses into
|
||||
// artist/tag/era/mood weight maps.
|
||||
func accumulate(
|
||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, cfg Config,
|
||||
) (map[pgtype.UUID]float64, map[string]float64, error) {
|
||||
) (map[pgtype.UUID]float64, map[string]float64, map[string]float64, map[string]float64, error) {
|
||||
plays, err := q.ListPlayEngagementInputsForUser(ctx, dbq.ListPlayEngagementInputsForUserParams{
|
||||
UserID: userID,
|
||||
Column2: cfg.WindowDays,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load play engagement: %w", err)
|
||||
}
|
||||
// Enriched folksonomy tags keyed by track (#1490) — folded into the tag
|
||||
// facet alongside raw ID3 genre so a coarse "Rock" gains the cached
|
||||
@@ -141,12 +175,14 @@ func accumulate(
|
||||
Column2: int32(cfg.WindowDays),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load played track tags: %w", err)
|
||||
}
|
||||
playedTags := groupTagsByTrack(playedTagRows)
|
||||
|
||||
artistW := make(map[pgtype.UUID]float64)
|
||||
tagW := make(map[string]float64)
|
||||
eraW := make(map[string]float64)
|
||||
moodW := make(map[string]float64)
|
||||
for _, p := range plays {
|
||||
e := Engagement(p.Completion, cfg.Engagement) * decay(p.AgeDays, cfg.HalfLifeDays)
|
||||
artistW[p.ArtistID] += e
|
||||
@@ -154,15 +190,17 @@ func accumulate(
|
||||
tagW[tag] += e
|
||||
}
|
||||
foldEnrichedTags(tagW, playedTags[p.TrackID], e, cfg.EnrichedTagScale)
|
||||
foldEra(eraW, p.ReleaseDate, e, cfg.EraScale)
|
||||
foldMoods(moodW, playedTags[p.TrackID], e, cfg.MoodScale)
|
||||
}
|
||||
|
||||
likedTracks, err := q.ListLikedTrackTasteInputsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load liked tracks: %w", err)
|
||||
}
|
||||
likedTagRows, err := q.ListLikedTrackTagsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load liked track tags: %w", err)
|
||||
}
|
||||
likedTags := groupTagsByTrack(likedTagRows)
|
||||
for _, lt := range likedTracks {
|
||||
@@ -171,16 +209,18 @@ func accumulate(
|
||||
tagW[tag] += cfg.TagLikeBonus
|
||||
}
|
||||
foldEnrichedTags(tagW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.EnrichedTagScale)
|
||||
foldEra(eraW, lt.ReleaseDate, cfg.TagLikeBonus, cfg.EraScale)
|
||||
foldMoods(moodW, likedTags[lt.TrackID], cfg.TagLikeBonus, cfg.MoodScale)
|
||||
}
|
||||
|
||||
likedArtists, err := q.ListLikedArtistIDsForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
|
||||
return nil, nil, nil, nil, fmt.Errorf("taste: load liked artists: %w", err)
|
||||
}
|
||||
for _, aid := range likedArtists {
|
||||
artistW[aid] += cfg.ArtistLikeBonus
|
||||
}
|
||||
return artistW, tagW, nil
|
||||
return artistW, tagW, eraW, moodW, nil
|
||||
}
|
||||
|
||||
// groupTagsByTrack buckets flat (track, tag, weight) rows by track id so
|
||||
@@ -206,10 +246,51 @@ func foldEnrichedTags(tagW map[string]float64, tags []dbq.TrackTag, base, scale
|
||||
}
|
||||
}
|
||||
|
||||
// foldEra adds a track's decade bucket to eraW weighted by base × scale — base
|
||||
// is the play's decayed engagement or the tag-like bonus, scale (EraScale)
|
||||
// dials the facet's strength. scale=0 disables the era facet; undated tracks
|
||||
// (decade "") contribute nothing so they don't pollute the profile.
|
||||
func foldEra(eraW map[string]float64, d pgtype.Date, base, scale float64) {
|
||||
if scale == 0 {
|
||||
return
|
||||
}
|
||||
if decade := decadeOf(d); decade != "" {
|
||||
eraW[decade] += base * scale
|
||||
}
|
||||
}
|
||||
|
||||
// foldMoods adds a track's canonical mood buckets (derived from its enriched
|
||||
// folksonomy tags via internal/mood) to moodW weighted by base × scale. base is
|
||||
// the play's decayed engagement or the tag-like bonus; scale (MoodScale) dials
|
||||
// the facet's strength. scale=0 disables the mood facet; tracks with no
|
||||
// mood-word tags contribute nothing.
|
||||
func foldMoods(moodW map[string]float64, tags []dbq.TrackTag, base, scale float64) {
|
||||
if scale == 0 {
|
||||
return
|
||||
}
|
||||
names := make([]string, len(tags))
|
||||
for i, t := range tags {
|
||||
names[i] = t.Tag
|
||||
}
|
||||
for _, m := range mood.Of(names) {
|
||||
moodW[m] += base * scale
|
||||
}
|
||||
}
|
||||
|
||||
// decadeOf maps an album release date to a decade bucket like "1990s", or ""
|
||||
// for an absent/invalid date. Mirrored by the recommendation scorer so a
|
||||
// candidate's era is derived the same way it was learned.
|
||||
func decadeOf(d pgtype.Date) string {
|
||||
if !d.Valid {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%ds", (d.Time.Year()/10)*10)
|
||||
}
|
||||
|
||||
// persist atomic-replaces the user's profile rows inside one transaction.
|
||||
func persist(
|
||||
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
|
||||
artists []weighted[pgtype.UUID], tags []weighted[string],
|
||||
artists []weighted[pgtype.UUID], tags, eras, moods []weighted[string],
|
||||
) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -238,6 +319,26 @@ func persist(
|
||||
return fmt.Errorf("taste: insert tag: %w", err)
|
||||
}
|
||||
}
|
||||
if err := qtx.DeleteTasteProfileErasForUser(ctx, userID); err != nil {
|
||||
return fmt.Errorf("taste: delete eras: %w", err)
|
||||
}
|
||||
for _, e := range eras {
|
||||
if err := qtx.InsertTasteProfileEra(ctx, dbq.InsertTasteProfileEraParams{
|
||||
UserID: userID, Era: e.Key, Weight: e.Weight,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("taste: insert era: %w", err)
|
||||
}
|
||||
}
|
||||
if err := qtx.DeleteTasteProfileMoodsForUser(ctx, userID); err != nil {
|
||||
return fmt.Errorf("taste: delete moods: %w", err)
|
||||
}
|
||||
for _, m := range moods {
|
||||
if err := qtx.InsertTasteProfileMood(ctx, dbq.InsertTasteProfileMoodParams{
|
||||
UserID: userID, Mood: m.Key, Weight: m.Weight,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("taste: insert mood: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("taste: commit: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":semanticCommits"
|
||||
],
|
||||
"baseBranches": ["dev"],
|
||||
"timezone": "America/New_York",
|
||||
"schedule": ["every weekend"],
|
||||
"prHourlyLimit": 2,
|
||||
"prConcurrentLimit": 8,
|
||||
"ignorePaths": [
|
||||
"**/node_modules/**",
|
||||
"**/vendor/**",
|
||||
"flutter_client/**"
|
||||
],
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": ["before 5am on the first day of the month"]
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Auto-merge patch/minor/digest/pin bumps once CI is green",
|
||||
"matchUpdateTypes": ["minor", "patch", "digest", "pin"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"description": "Hold all major bumps for manual approval via the dependency dashboard",
|
||||
"matchUpdateTypes": ["major"],
|
||||
"automerge": false,
|
||||
"dependencyDashboardApproval": true,
|
||||
"addLabels": ["deps", "deps:major"]
|
||||
},
|
||||
{
|
||||
"description": "Group Go module updates into one PR",
|
||||
"matchManagers": ["gomod"],
|
||||
"groupName": "go modules"
|
||||
},
|
||||
{
|
||||
"description": "Group CI workflow action bumps",
|
||||
"matchManagers": ["github-actions"],
|
||||
"groupName": "ci actions"
|
||||
},
|
||||
{
|
||||
"description": "Group Docker base-image bumps (Dockerfile + compose)",
|
||||
"matchManagers": ["dockerfile", "docker-compose"],
|
||||
"groupName": "docker images"
|
||||
},
|
||||
{
|
||||
"description": "Group the Android Gradle/Kotlin toolchain",
|
||||
"matchManagers": ["gradle", "gradle-wrapper"],
|
||||
"groupName": "android gradle"
|
||||
},
|
||||
{
|
||||
"description": "Group web npm non-major bumps",
|
||||
"matchManagers": ["npm"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"groupName": "web npm (non-major)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export type WeightProfile = {
|
||||
context_weight: number;
|
||||
similarity_weight: number;
|
||||
taste_weight: number;
|
||||
context_time_weight: number;
|
||||
};
|
||||
|
||||
export type TasteTuning = {
|
||||
@@ -20,6 +21,9 @@ export type TasteTuning = {
|
||||
engagement_hard_skip: number;
|
||||
engagement_neutral: number;
|
||||
engagement_full: number;
|
||||
enriched_tag_scale: number;
|
||||
era_scale: number;
|
||||
mood_scale: number;
|
||||
};
|
||||
|
||||
export type TuningScope = 'radio' | 'daily_mix' | 'taste';
|
||||
|
||||
@@ -124,7 +124,10 @@ export function useEventsDispatcher(): void {
|
||||
// #415: tag the play with the system playlist it came from
|
||||
// (null for library / user-playlist / radio) so the server
|
||||
// can advance that playlist's rotation.
|
||||
source: player.queueSource ?? undefined
|
||||
source: player.queueSource ?? undefined,
|
||||
// #1551: device class for context conditioning. The web app is a
|
||||
// browser session, so it reports 'web'.
|
||||
device_class: 'web'
|
||||
});
|
||||
// The user may have moved on by the time the response arrives. Only
|
||||
// adopt the id if we're still on the same track and still playing.
|
||||
|
||||
@@ -29,14 +29,18 @@
|
||||
{ key: 'jitter_magnitude', label: 'Jitter', hint: 'Random reshuffle magnitude for near-ties.' },
|
||||
{ key: 'context_weight', label: 'Context weight', hint: 'Session-vector similarity contribution.' },
|
||||
{ key: 'similarity_weight', label: 'Similarity weight', hint: 'Seed-similarity contribution.' },
|
||||
{ key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' }
|
||||
{ key: 'taste_weight', label: 'Taste weight', hint: 'Learned taste-profile fit, in [-1, +1].' },
|
||||
{ key: 'context_time_weight', label: 'Time-of-day weight', hint: "Artist's time-of-day/weekday affinity for the current context, in [-1, +1]. 0 = ignore when you listen." }
|
||||
];
|
||||
|
||||
const tasteFields: { key: keyof TasteTuning; label: string; hint: string }[] = [
|
||||
{ key: 'half_life_days', label: 'Half-life (days)', hint: "A play's influence halves every this-many days." },
|
||||
{ key: 'engagement_hard_skip', label: 'Hard-skip point', hint: 'Completion at/below which a play reads −1.' },
|
||||
{ key: 'engagement_neutral', label: 'Neutral point', hint: 'Completion at which a play reads 0.' },
|
||||
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' }
|
||||
{ key: 'engagement_full', label: 'Full point', hint: 'Completion at/above which a play reads +1.' },
|
||||
{ key: 'enriched_tag_scale', label: 'Enriched tag weight', hint: 'How much folksonomy tags (MusicBrainz/Last.fm) count vs raw file genre, in [0, 1]. 0 = genre only.' },
|
||||
{ key: 'era_scale', label: 'Era weight', hint: 'How strongly a decade-play imprints on the era facet, in [0, 1]. 0 = era ignored.' },
|
||||
{ key: 'mood_scale', label: 'Mood weight', hint: 'How strongly a mood-tagged play imprints on the mood facet (from folksonomy tags), in [0, 1]. 0 = mood ignored.' }
|
||||
];
|
||||
|
||||
const profileScopes: { scope: 'radio' | 'daily_mix'; label: string; blurb: string }[] = [
|
||||
|
||||
@@ -28,6 +28,7 @@ const weights = (over: Partial<Record<string, number>> = {}) => ({
|
||||
context_weight: 0.5,
|
||||
similarity_weight: 1.5,
|
||||
taste_weight: 1.5,
|
||||
context_time_weight: 1,
|
||||
...over
|
||||
});
|
||||
|
||||
@@ -36,6 +37,9 @@ const taste = (over: Partial<Record<string, number>> = {}) => ({
|
||||
engagement_hard_skip: 0.05,
|
||||
engagement_neutral: 0.3,
|
||||
engagement_full: 0.9,
|
||||
enriched_tag_scale: 0.5,
|
||||
era_scale: 0.5,
|
||||
mood_scale: 0.5,
|
||||
...over
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user