Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e258507bb | |||
| fd7d6dac4c | |||
| d6290a3ef0 | |||
| 9550d8daaf | |||
| 4d5af3599e | |||
| db393bbe65 | |||
| 1a7515e6ea | |||
| 6c26ba807e | |||
| c7adf2c87a |
@@ -0,0 +1,77 @@
|
|||||||
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds a WiFi + CPU wake lock for the lifetime of an active UPnP cast.
|
||||||
|
*
|
||||||
|
* While casting, the wrapped ExoPlayer is paused, so its automatic
|
||||||
|
* `WAKE_MODE_NETWORK` locks are released and nothing keeps the phone's radio
|
||||||
|
* awake. On a locked, on-battery phone the WiFi enters power-save within
|
||||||
|
* seconds and the CPU dozes; that stalls the 1 Hz liveness poll to the
|
||||||
|
* renderer and the queue-extend calls to the server. The poll then trips the
|
||||||
|
* drop threshold and playback falls back to a phone that *also* has no
|
||||||
|
* network — silence, while the renderer was streaming fine the whole time.
|
||||||
|
*
|
||||||
|
* A high-performance / low-latency [WifiManager.WifiLock] keeps the radio out
|
||||||
|
* of power-save, and a partial [PowerManager.WakeLock] keeps the poll
|
||||||
|
* coroutine scheduled. Both are acquired when a route goes active and released
|
||||||
|
* the moment it drops or the user switches back to the phone.
|
||||||
|
*
|
||||||
|
* All methods are idempotent (`setReferenceCounted(false)` + held-checks) and
|
||||||
|
* synchronized, so they are safe to call from any thread.
|
||||||
|
*/
|
||||||
|
class CastNetworkLock(context: Context) {
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
|
||||||
|
private val wifiLock: WifiManager.WifiLock? =
|
||||||
|
(appContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager)
|
||||||
|
?.createWifiLock(wifiLockMode(), LOCK_TAG)
|
||||||
|
?.apply { setReferenceCounted(false) }
|
||||||
|
|
||||||
|
private val wakeLock: PowerManager.WakeLock? =
|
||||||
|
(appContext.getSystemService(Context.POWER_SERVICE) as? PowerManager)
|
||||||
|
?.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, LOCK_TAG)
|
||||||
|
?.apply { setReferenceCounted(false) }
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun acquire() {
|
||||||
|
runCatching { wifiLock?.takeUnless { it.isHeld }?.acquire() }
|
||||||
|
.onFailure { Timber.w(it, "CastNetworkLock: wifi acquire failed") }
|
||||||
|
runCatching { wakeLock?.takeUnless { it.isHeld }?.acquire() }
|
||||||
|
.onFailure { Timber.w(it, "CastNetworkLock: wake acquire failed") }
|
||||||
|
Timber.w(
|
||||||
|
"CastNetworkLock acquired (wifi=%s wake=%s)",
|
||||||
|
wifiLock?.isHeld, wakeLock?.isHeld,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun release() {
|
||||||
|
runCatching { wifiLock?.takeIf { it.isHeld }?.release() }
|
||||||
|
.onFailure { Timber.w(it, "CastNetworkLock: wifi release failed") }
|
||||||
|
runCatching { wakeLock?.takeIf { it.isHeld }?.release() }
|
||||||
|
.onFailure { Timber.w(it, "CastNetworkLock: wake release failed") }
|
||||||
|
Timber.w("CastNetworkLock released")
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LOCK_TAG = "minstrel:upnp-cast"
|
||||||
|
|
||||||
|
// WIFI_MODE_FULL_LOW_LATENCY (API 29+) both disables power-save and
|
||||||
|
// lowers latency; FULL_HIGH_PERF is the pre-Q equivalent for keeping
|
||||||
|
// the radio fully awake.
|
||||||
|
fun wifiLockMode(): Int =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
WifiManager.WIFI_MODE_FULL_HIGH_PERF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
-3
@@ -10,6 +10,8 @@ import androidx.lifecycle.ProcessLifecycleOwner
|
|||||||
import androidx.media3.common.ForwardingPlayer
|
import androidx.media3.common.ForwardingPlayer
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
|
import com.fabledsword.minstrel.connectivity.NetworkStatusController
|
||||||
|
import com.fabledsword.minstrel.connectivity.ServerHealth
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
@@ -63,6 +65,8 @@ class MinstrelForwardingPlayer(
|
|||||||
private val delegate: Player,
|
private val delegate: Player,
|
||||||
private val holder: ActiveUpnpHolder,
|
private val holder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
|
private val castNetworkLock: CastNetworkLock,
|
||||||
|
private val networkStatus: NetworkStatusController,
|
||||||
private val onDrop: (routeName: String) -> Unit,
|
private val onDrop: (routeName: String) -> Unit,
|
||||||
) : ForwardingPlayer(delegate) {
|
) : ForwardingPlayer(delegate) {
|
||||||
|
|
||||||
@@ -116,6 +120,20 @@ class MinstrelForwardingPlayer(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
holder.active.collect { active -> onActiveChanged(active) }
|
holder.active.collect { active -> onActiveChanged(active) }
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
// When the phone's own network recovers, reconcile a held cast
|
||||||
|
// session immediately instead of waiting up to POLL_INTERVAL_MS:
|
||||||
|
// the renderer kept playing, so one round-trip re-confirms it and
|
||||||
|
// resyncs the cursor/position. Only a transition *into* Healthy
|
||||||
|
// while a route is active matters.
|
||||||
|
var prev = networkStatus.state.value
|
||||||
|
networkStatus.state.collect { next ->
|
||||||
|
if (next == ServerHealth.Healthy && prev != ServerHealth.Healthy && isRemote()) {
|
||||||
|
pollTrigger.trySend(Unit)
|
||||||
|
}
|
||||||
|
prev = next
|
||||||
|
}
|
||||||
|
}
|
||||||
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
// Process lifecycle is observed on the main thread; ProcessLifecycleOwner's
|
||||||
// addObserver requires it. The observer just trySend's to the channel.
|
// addObserver requires it. The observer just trySend's to the channel.
|
||||||
handler.post {
|
handler.post {
|
||||||
@@ -438,6 +456,11 @@ class MinstrelForwardingPlayer(
|
|||||||
lastNotifiedTrackIdx = -1
|
lastNotifiedTrackIdx = -1
|
||||||
if (active != null) {
|
if (active != null) {
|
||||||
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
Timber.w("UPnP active: %s -- pollLoop starting", active.routeName)
|
||||||
|
// Hold the WiFi + CPU awake for the cast: the wrapped ExoPlayer is
|
||||||
|
// about to be paused (releasing its WAKE_MODE_NETWORK locks), so
|
||||||
|
// without this the radio power-saves on a locked screen and the
|
||||||
|
// poll below starves -- see [CastNetworkLock].
|
||||||
|
castNetworkLock.acquire()
|
||||||
// Pause the wrapped ExoPlayer so we are not playing local audio
|
// Pause the wrapped ExoPlayer so we are not playing local audio
|
||||||
// simultaneously with the remote renderer. handler.post targets the
|
// simultaneously with the remote renderer. handler.post targets the
|
||||||
// application looper, so this runs on the same thread that processes
|
// application looper, so this runs on the same thread that processes
|
||||||
@@ -447,20 +470,26 @@ class MinstrelForwardingPlayer(
|
|||||||
handler.post { delegate.pause() }
|
handler.post { delegate.pause() }
|
||||||
pollJob = scope.launch { pollLoop(active) }
|
pollJob = scope.launch { pollLoop(active) }
|
||||||
} else {
|
} else {
|
||||||
|
castNetworkLock.release()
|
||||||
remoteState.reset()
|
remoteState.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
@OptIn(ExperimentalCoroutinesApi::class) // onTimeout / select.onReceive
|
||||||
private suspend fun pollLoop(active: ActiveUpnp) {
|
private suspend fun pollLoop(active: ActiveUpnp) {
|
||||||
|
var networkDropSuppressed = false
|
||||||
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
while (scope.isActive && holder.active.value?.routeId == active.routeId) {
|
||||||
val outcome = runCatching { pollOnce(active) }
|
val outcome = runCatching { pollOnce(active) }
|
||||||
if (outcome.isSuccess) {
|
if (outcome.isSuccess) {
|
||||||
remoteState.recordPollSuccess()
|
remoteState.recordPollSuccess()
|
||||||
|
networkDropSuppressed = false
|
||||||
} else if (remoteState.recordPollFailure()) {
|
} else if (remoteState.recordPollFailure()) {
|
||||||
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
if (networkStatus.state.value == ServerHealth.Healthy) {
|
||||||
handler.post { onDrop(active.routeName) }
|
Timber.w("UPnP drop threshold tripped for %s", active.routeName)
|
||||||
return
|
handler.post { onDrop(active.routeName) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
networkDropSuppressed = suppressDropForNetwork(active, networkDropSuppressed)
|
||||||
}
|
}
|
||||||
// Race the normal cadence against any external wake (activity
|
// Race the normal cadence against any external wake (activity
|
||||||
// resume). Whichever wins continues to the next pollOnce.
|
// resume). Whichever wins continues to the next pollOnce.
|
||||||
@@ -471,6 +500,30 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The poll-failure threshold tripped, but the phone's *own* network is down
|
||||||
|
* (state is not [ServerHealth.Healthy]) -- so this is "we can't see the
|
||||||
|
* renderer right now," not "the renderer died." A UPnP renderer streams
|
||||||
|
* autonomously and is almost certainly still playing; meanwhile a local
|
||||||
|
* fallback couldn't reach the server either, so dropping would only blast
|
||||||
|
* local audio out of a pocketed phone while the speaker keeps going. Hold
|
||||||
|
* the route and keep polling -- a successful poll once the network returns
|
||||||
|
* reconciles to the renderer's real state. Clear the failure streak so
|
||||||
|
* recovery re-evaluates the renderer from scratch instead of re-dropping on
|
||||||
|
* the first post-recovery hiccup. Returns the (latched) suppression flag so
|
||||||
|
* the rationale logs once per outage, not every tick.
|
||||||
|
*/
|
||||||
|
private fun suppressDropForNetwork(active: ActiveUpnp, alreadySuppressed: Boolean): Boolean {
|
||||||
|
if (!alreadySuppressed) {
|
||||||
|
Timber.w(
|
||||||
|
"UPnP drop suppressed for %s -- phone network %s, holding route",
|
||||||
|
active.routeName, networkStatus.state.value,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
remoteState.recordPollSuccess() // clear streak; not a renderer failure
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One poll tick: read position + transport state from Sonos, apply to
|
* One poll tick: read position + transport state from Sonos, apply to
|
||||||
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
* [remoteState], and forward-sync the local cursor to Sonos's Track
|
||||||
|
|||||||
@@ -192,6 +192,20 @@ class PlayerController @Inject constructor(
|
|||||||
controller.play()
|
controller.play()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Align the local cursor to [index] + [positionMs] WITHOUT auto-playing.
|
||||||
|
* Used by the UPnP adopt path: when attaching to a renderer that's already
|
||||||
|
* playing our queue, we sync the local cursor to the renderer's current
|
||||||
|
* track/position so skip/seek and the UI map onto its live session — the
|
||||||
|
* renderer keeps the audio, the local player stays paused. Unlike
|
||||||
|
* [seekToIndex] this issues no play().
|
||||||
|
*/
|
||||||
|
fun moveCursorTo(index: Int, positionMs: Long) {
|
||||||
|
val controller = mediaController ?: return
|
||||||
|
if (index !in queueRefs.indices) return
|
||||||
|
runOnControllerThread(controller) { controller.seekTo(index, positionMs) }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the queue with [tracks] starting at [initialIndex].
|
* Replace the queue with [tracks] starting at [initialIndex].
|
||||||
* [source] tags the queue with its origin (e.g. "for_you") so the
|
* [source] tags the queue with its origin (e.g. "for_you") so the
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ class PlayerFactory @Inject constructor(
|
|||||||
delegate = exo,
|
delegate = exo,
|
||||||
holder = activeUpnpHolder,
|
holder = activeUpnpHolder,
|
||||||
remoteState = remoteState,
|
remoteState = remoteState,
|
||||||
|
castNetworkLock = CastNetworkLock(context),
|
||||||
|
networkStatus = serverHealth,
|
||||||
onDrop = { name -> emitDrop(name) },
|
onDrop = { name -> emitDrop(name) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+130
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.player.output.upnp.AVTransportClient
|
|||||||
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
import com.fabledsword.minstrel.player.output.upnp.RenderingControlClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
import com.fabledsword.minstrel.player.output.upnp.SoapClient
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
import com.fabledsword.minstrel.player.output.upnp.UpnpDiscoveryController
|
||||||
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
import com.fabledsword.minstrel.player.output.upnp.bareUdn
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@@ -29,6 +30,7 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -172,6 +174,31 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
scope.launch { observeQueueChangesForSonosResync() }
|
scope.launch { observeQueueChangesForSonosResync() }
|
||||||
scope.launch { observeIdleRevertWhileUpnp() }
|
scope.launch { observeIdleRevertWhileUpnp() }
|
||||||
|
scope.launch { observeSelectedRouteDisappearance() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revert the UPnP selection to the phone when the selected renderer leaves
|
||||||
|
* discovery (powered off / off the LAN) while we are NOT actively casting
|
||||||
|
* to it. Without this the selection could pin to a ghost route, so a later
|
||||||
|
* "play" would target a speaker that's gone. An *active* cast is left alone
|
||||||
|
* -- its own poll loop arbitrates liveness and won't drop on a mere
|
||||||
|
* discovery gap. Routes leave discovery via [UpnpDiscoveryController]'s
|
||||||
|
* post-picker-scan prune; this reacts to the resulting routes change.
|
||||||
|
*/
|
||||||
|
private suspend fun observeSelectedRouteDisappearance() {
|
||||||
|
upnpDiscovery.routes.collect { routes ->
|
||||||
|
val selected = selectedUpnpRouteIdInternal.value
|
||||||
|
val present = routes.any { it.id == selected }
|
||||||
|
val casting = activeUpnpHolder.active.value?.routeId == selected
|
||||||
|
if (selected != null && !present && !casting) {
|
||||||
|
Timber.w(
|
||||||
|
"Output: selected UPnP route %s left discovery; reverting to phone",
|
||||||
|
selected,
|
||||||
|
)
|
||||||
|
selectedUpnpRouteIdInternal.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -541,6 +568,13 @@ class OutputPickerController @Inject constructor(
|
|||||||
// local audio while we queue up Sonos.
|
// local audio while we queue up Sonos.
|
||||||
playerController.pause()
|
playerController.pause()
|
||||||
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
selectedUpnpRouteIdInternal.value = effectiveRoute.id
|
||||||
|
// If the renderer is already playing this exact queue (a "stray"
|
||||||
|
// session we got disconnected from), attach to it in place instead of
|
||||||
|
// wiping + reloading -- no jarring restart, and skip/seek land on its
|
||||||
|
// live queue. Falls through to the clear+reload path when it isn't ours.
|
||||||
|
if (tryAdoptRunningSession(transport, rendering, effectiveRoute, uiState.queue)) {
|
||||||
|
return@withLock
|
||||||
|
}
|
||||||
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
// Mark UPnP loading. ForwardingPlayer overrides drop transport commands
|
||||||
// silently while target is set but active is null -- the user's premature
|
// silently while target is set but active is null -- the user's premature
|
||||||
// taps don't hit Sonos's stale state from a prior session.
|
// taps don't hit Sonos's stale state from a prior session.
|
||||||
@@ -567,6 +601,102 @@ class OutputPickerController @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The renderer's current position + transport state, validated as an
|
||||||
|
* adoptable continuation of our local queue: same track id at the same
|
||||||
|
* 1-based index. Null means "not ours / not playing" -> caller reloads.
|
||||||
|
*/
|
||||||
|
private data class AdoptPlan(
|
||||||
|
val localIndex: Int,
|
||||||
|
val positionMs: Long,
|
||||||
|
val durationMs: Long,
|
||||||
|
val trackUri: String,
|
||||||
|
val trackNumber: Int,
|
||||||
|
val playing: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach to a renderer that is *already* playing our queue (a session the
|
||||||
|
* phone got disconnected from but the speaker kept streaming) instead of
|
||||||
|
* clearing + reloading it. Probes the renderer's live state, and if it's
|
||||||
|
* mid-playback on the same track id at the same queue index, syncs the
|
||||||
|
* local cursor to match and wires [ActiveUpnpHolder] -- the speaker never
|
||||||
|
* skips a beat, and skip/seek now drive its live queue. Returns false (and
|
||||||
|
* the caller does the full clear+reload) when the renderer isn't playing
|
||||||
|
* our queue. The local player is already paused by the caller.
|
||||||
|
*/
|
||||||
|
private suspend fun tryAdoptRunningSession(
|
||||||
|
transport: AVTransportClient,
|
||||||
|
rendering: RenderingControlClient?,
|
||||||
|
route: OutputRoute,
|
||||||
|
localQueue: List<TrackRef>,
|
||||||
|
): Boolean {
|
||||||
|
val plan = probeAdoptable(transport, route, localQueue) ?: return false
|
||||||
|
Timber.w(
|
||||||
|
"UPnP adopt: attaching to live session on %s at track %d (%dms)",
|
||||||
|
route.name, plan.trackNumber, plan.positionMs,
|
||||||
|
)
|
||||||
|
// active is still null here, so this aligns the *local* cursor without
|
||||||
|
// emitting SOAP; the holder.set below then starts the poll loop, which
|
||||||
|
// reconciles to the renderer's advancing position from here.
|
||||||
|
playerController.moveCursorTo(plan.localIndex, plan.positionMs)
|
||||||
|
remoteState.applyPositionInfo(
|
||||||
|
positionMs = plan.positionMs,
|
||||||
|
durationMs = plan.durationMs,
|
||||||
|
trackUri = plan.trackUri,
|
||||||
|
trackNumber = plan.trackNumber,
|
||||||
|
)
|
||||||
|
remoteState.setPlayIntent(plan.playing)
|
||||||
|
activeUpnpHolder.set(
|
||||||
|
ActiveUpnp(
|
||||||
|
routeId = route.id,
|
||||||
|
routeName = route.name,
|
||||||
|
avTransport = transport,
|
||||||
|
rendering = rendering,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun probeAdoptable(
|
||||||
|
transport: AVTransportClient,
|
||||||
|
route: OutputRoute,
|
||||||
|
localQueue: List<TrackRef>,
|
||||||
|
): AdoptPlan? {
|
||||||
|
val probe = runCatching {
|
||||||
|
transport.getTransportInfo() to transport.getPositionInfo()
|
||||||
|
}.getOrElse {
|
||||||
|
Timber.w(it, "UPnP adopt: probe failed for %s; will reload", route.name)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val (info, pos) = probe
|
||||||
|
val idx0 = pos.track - 1
|
||||||
|
val playingId = trackIdFromStreamUrl(pos.trackUri)
|
||||||
|
val midPlayback =
|
||||||
|
info.state == TransportState.PLAYING || info.state == TransportState.PAUSED
|
||||||
|
val aligned = midPlayback && playingId != null &&
|
||||||
|
idx0 in localQueue.indices && localQueue[idx0].id == playingId
|
||||||
|
return if (aligned) {
|
||||||
|
AdoptPlan(
|
||||||
|
localIndex = idx0,
|
||||||
|
positionMs = pos.relTimeMs,
|
||||||
|
durationMs = pos.trackDurationMs,
|
||||||
|
trackUri = pos.trackUri,
|
||||||
|
trackNumber = pos.track,
|
||||||
|
playing = info.state == TransportState.PLAYING,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract our track id from a stream URL `.../api/tracks/<id>/stream...`. */
|
||||||
|
private fun trackIdFromStreamUrl(url: String): String? {
|
||||||
|
val segments = url.toHttpUrlOrNull()?.pathSegments ?: return null
|
||||||
|
val i = segments.indexOf("tracks")
|
||||||
|
return if (i >= 0) segments.getOrNull(i + 1) else null
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun loadQueueOnSonos(
|
private suspend fun loadQueueOnSonos(
|
||||||
transport: AVTransportClient,
|
transport: AVTransportClient,
|
||||||
route: OutputRoute,
|
route: OutputRoute,
|
||||||
|
|||||||
+39
-1
@@ -8,6 +8,7 @@ import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
|
|||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -17,6 +18,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -58,6 +60,12 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
|
||||||
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()
|
||||||
|
|
||||||
|
// Wall-clock of the last NOTIFY / M-SEARCH response per route id. Drives
|
||||||
|
// post-active-scan pruning so a powered-off renderer leaves the picker
|
||||||
|
// (and can't pin a stale selection) instead of lingering for the process
|
||||||
|
// lifetime -- upsert only ever added before.
|
||||||
|
private val lastSeenMs = ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
|
||||||
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()
|
||||||
|
|
||||||
@@ -78,7 +86,31 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
* that just opened).
|
* that just opened).
|
||||||
*/
|
*/
|
||||||
fun upgradeDiscovery() {
|
fun upgradeDiscovery() {
|
||||||
appScope.launch { ssdp.requestActiveScan() }
|
appScope.launch {
|
||||||
|
val scanStartMs = System.currentTimeMillis()
|
||||||
|
ssdp.requestActiveScan()
|
||||||
|
// Give present devices time to answer the M-SEARCH (+ our follow-up
|
||||||
|
// description fetch), then drop any route that didn't re-announce.
|
||||||
|
// Pruning is tied to picker-open scans only -- no background timer,
|
||||||
|
// so route rows never flicker while the user isn't looking.
|
||||||
|
delay(ACTIVE_SCAN_PRUNE_DELAY_MS)
|
||||||
|
pruneRoutesNotSeenSince(scanStartMs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop routes whose last NOTIFY / M-SEARCH response predates [cutoffMs]
|
||||||
|
* -- i.e. they didn't answer the active scan that just ran, so they're
|
||||||
|
* gone from the LAN. Called only after [upgradeDiscovery]'s scan window.
|
||||||
|
*/
|
||||||
|
private fun pruneRoutesNotSeenSince(cutoffMs: Long) {
|
||||||
|
val before = routesInternal.value
|
||||||
|
val survivors = before.filter { (lastSeenMs[it.id] ?: 0L) >= cutoffMs }
|
||||||
|
if (survivors.size == before.size) return
|
||||||
|
val removed = before.filterNot { it in survivors }.map { it.id }
|
||||||
|
removed.forEach { lastSeenMs.remove(it) }
|
||||||
|
Timber.w("UPnP discovery: pruned %d stale route(s): %s", removed.size, removed)
|
||||||
|
routesInternal.value = survivors
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -115,6 +147,7 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun upsertRoute(route: UpnpRoute) {
|
private fun upsertRoute(route: UpnpRoute) {
|
||||||
|
lastSeenMs[route.id] = System.currentTimeMillis()
|
||||||
val current = routesInternal.value
|
val current = routesInternal.value
|
||||||
val idx = current.indexOfFirst { it.id == route.id }
|
val idx = current.indexOfFirst { it.id == route.id }
|
||||||
routesInternal.value = if (idx < 0) {
|
routesInternal.value = if (idx < 0) {
|
||||||
@@ -240,6 +273,11 @@ class UpnpDiscoveryController @Inject constructor(
|
|||||||
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
// trips the drop-recovery quickly. Conservative enough not to false-drop
|
||||||
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
// a slow-but-alive LAN; read timeout still inherits the shared client.
|
||||||
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L
|
||||||
|
|
||||||
|
// Grace window after an M-SEARCH burst before pruning non-responders.
|
||||||
|
// Covers the SSDP round-trip plus our per-device description fetch;
|
||||||
|
// generous enough that a present-but-slow renderer isn't false-pruned.
|
||||||
|
const val ACTIVE_SCAN_PRUNE_DELAY_MS = 4_000L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
|||||||
authed.Post("/auth/logout", h.handleLogout)
|
authed.Post("/auth/logout", h.handleLogout)
|
||||||
authed.Get("/me", h.handleGetMe)
|
authed.Get("/me", h.handleGetMe)
|
||||||
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
authed.Get("/me/system-playlists-status", h.handleGetSystemPlaylistsStatus)
|
||||||
|
authed.Get("/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||||
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
authed.Get("/me/listenbrainz", h.handleGetListenBrainz)
|
||||||
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
authed.Put("/me/listenbrainz", h.handlePutListenBrainz)
|
||||||
authed.Get("/me/history", h.handleGetMyHistory)
|
authed.Get("/me/history", h.handleGetMyHistory)
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
recMetricsDefaultDays = 30
|
||||||
|
recMetricsMaxDays = 365
|
||||||
|
)
|
||||||
|
|
||||||
|
// recommendationMetric is one recommendation surface's outcomes.
|
||||||
|
type recommendationMetric struct {
|
||||||
|
Source string `json:"source"` // 'for_you' | 'discover' | mixes
|
||||||
|
Plays int64 `json:"plays"` // plays launched from this surface
|
||||||
|
Skips int64 `json:"skips"` // of those, marked skipped
|
||||||
|
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||||
|
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||||
|
}
|
||||||
|
|
||||||
|
type recommendationMetricsResp struct {
|
||||||
|
WindowDays int `json:"window_days"`
|
||||||
|
Sources []recommendationMetric `json:"sources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||||
|
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
|
||||||
|
// caller over the last `days` (default 30, capped at 365), so the operator can
|
||||||
|
// see which recommendation surfaces are landing and tune the taste weights.
|
||||||
|
// Only plays tagged with a system-playlist source count; library/radio plays
|
||||||
|
// (no source) are excluded.
|
||||||
|
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
caller, ok := requireUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
days, ok := parseMetricsDays(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := dbq.New(h.pool).RecommendationSourceMetricsForUser(r.Context(),
|
||||||
|
dbq.RecommendationSourceMetricsForUserParams{UserID: caller.ID, Column2: float64(days)})
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("api: recommendation metrics", "err", err)
|
||||||
|
writeErr(w, apierror.InternalMsg("lookup failed", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := recommendationMetricsResp{
|
||||||
|
WindowDays: days,
|
||||||
|
Sources: make([]recommendationMetric, 0, len(rows)),
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
source := ""
|
||||||
|
if row.Source != nil {
|
||||||
|
source = *row.Source
|
||||||
|
}
|
||||||
|
var skipRate float64
|
||||||
|
if row.Plays > 0 {
|
||||||
|
skipRate = float64(row.Skips) / float64(row.Plays)
|
||||||
|
}
|
||||||
|
out.Sources = append(out.Sources, recommendationMetric{
|
||||||
|
Source: source,
|
||||||
|
Plays: row.Plays,
|
||||||
|
Skips: row.Skips,
|
||||||
|
SkipRate: skipRate,
|
||||||
|
AvgCompletion: row.AvgCompletion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
||||||
|
// Writes a 400 and returns ok=false on a malformed value.
|
||||||
|
func parseMetricsDays(w http.ResponseWriter, r *http.Request) (int, bool) {
|
||||||
|
v := r.URL.Query().Get("days")
|
||||||
|
if v == "" {
|
||||||
|
return recMetricsDefaultDays, true
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n < 1 {
|
||||||
|
writeErr(w, apierror.BadRequest("bad_request", "invalid days"))
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
if n > recMetricsMaxDays {
|
||||||
|
n = recMetricsMaxDays
|
||||||
|
}
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newMetricsRouter(h *handlers) chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Get("/api/me/recommendation-metrics", h.handleGetRecommendationMetrics)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
||||||
|
// skip flag. A nil source inserts NULL (library/radio play).
|
||||||
|
func seedSourcedPlay(
|
||||||
|
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
||||||
|
source *string, completion float64, skipped bool,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := h.pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO play_events
|
||||||
|
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
|
||||||
|
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
|
||||||
|
userID, trackID, sessionID, source, completion, skipped); err != nil {
|
||||||
|
t.Fatalf("seed sourced play: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationMetrics_NoSession401(t *testing.T) {
|
||||||
|
h, _ := testHandlers(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newMetricsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("status = %d, want 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
||||||
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
}
|
||||||
|
h, pool := testHandlers(t)
|
||||||
|
user := seedUser(t, pool, "metrics", "pw", false)
|
||||||
|
artist := seedArtist(t, pool, "MetricArtist")
|
||||||
|
album := seedAlbum(t, pool, artist.ID, "MetricAlbum", 2020)
|
||||||
|
tk := seedTrack(t, pool, album.ID, artist.ID, "MetricTrack", 1, 200000)
|
||||||
|
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||||
|
|
||||||
|
forYou := "for_you"
|
||||||
|
discover := "discover"
|
||||||
|
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
|
||||||
|
// discover: 1 play.
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
|
||||||
|
// library play (NULL source) — must be excluded.
|
||||||
|
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||||
|
req = withUser(req, user)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
newMetricsRouter(h).ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp recommendationMetricsResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if resp.WindowDays != recMetricsDefaultDays {
|
||||||
|
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
||||||
|
}
|
||||||
|
bySource := map[string]recommendationMetric{}
|
||||||
|
for _, m := range resp.Sources {
|
||||||
|
bySource[m.Source] = m
|
||||||
|
}
|
||||||
|
if _, present := bySource[""]; present {
|
||||||
|
t.Error("NULL-source (library) plays should be excluded")
|
||||||
|
}
|
||||||
|
fy, ok := bySource["for_you"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("for_you metrics missing")
|
||||||
|
}
|
||||||
|
if fy.Plays != 3 || fy.Skips != 1 {
|
||||||
|
t.Errorf("for_you plays/skips = %d/%d, want 3/1", fy.Plays, fy.Skips)
|
||||||
|
}
|
||||||
|
if fy.SkipRate < 0.32 || fy.SkipRate > 0.34 {
|
||||||
|
t.Errorf("for_you skip_rate = %.3f, want ~0.333", fy.SkipRate)
|
||||||
|
}
|
||||||
|
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
||||||
|
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
||||||
|
}
|
||||||
|
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
|
||||||
|
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -737,6 +737,17 @@ likes_overlap AS (
|
|||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $8
|
LIMIT $8
|
||||||
),
|
),
|
||||||
|
taste_overlap AS (
|
||||||
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||||
|
WHERE tpa.user_id = $1
|
||||||
|
AND tpa.weight > 0
|
||||||
|
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
ORDER BY tpa.weight DESC, t.id
|
||||||
|
LIMIT $10
|
||||||
|
),
|
||||||
random_fill AS (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -747,6 +758,7 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM similar_artists
|
UNION SELECT track_id FROM similar_artists
|
||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
|
UNION SELECT track_id FROM taste_overlap
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
LIMIT $9
|
||||||
@@ -763,6 +775,7 @@ FROM (
|
|||||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id
|
||||||
@@ -790,6 +803,7 @@ type LoadRadioCandidatesV2Params struct {
|
|||||||
Limit_3 int32
|
Limit_3 int32
|
||||||
Limit_4 int32
|
Limit_4 int32
|
||||||
Limit_5 int32
|
Limit_5 int32
|
||||||
|
Limit_6 int32
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoadRadioCandidatesV2Row struct {
|
type LoadRadioCandidatesV2Row struct {
|
||||||
@@ -801,11 +815,15 @@ type LoadRadioCandidatesV2Row struct {
|
|||||||
SimilarityScore interface{}
|
SimilarityScore interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// M4c: similarity-driven candidate pool. 5-way UNION:
|
// M4c: similarity-driven candidate pool. 6-way UNION:
|
||||||
//
|
//
|
||||||
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
// $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||||
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
// $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||||||
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
|
// $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||||||
|
// $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).
|
||||||
//
|
//
|
||||||
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
// Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandidatesV2Params) ([]LoadRadioCandidatesV2Row, error) {
|
||||||
@@ -819,6 +837,7 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
|
|||||||
arg.Limit_3,
|
arg.Limit_3,
|
||||||
arg.Limit_4,
|
arg.Limit_4,
|
||||||
arg.Limit_5,
|
arg.Limit_5,
|
||||||
|
arg.Limit_6,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// sqlc v1.31.1
|
||||||
|
// source: recommendation_metrics.sql
|
||||||
|
|
||||||
|
package dbq
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetricsForUser :many
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
pe.source,
|
||||||
|
count(*)::bigint AS plays,
|
||||||
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
|
COALESCE(
|
||||||
|
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
||||||
|
0)::float8 AS avg_completion
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.source IS NOT NULL
|
||||||
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
|
GROUP BY pe.source
|
||||||
|
ORDER BY plays DESC
|
||||||
|
`
|
||||||
|
|
||||||
|
type RecommendationSourceMetricsForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Column2 float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecommendationSourceMetricsForUserRow struct {
|
||||||
|
Source *string
|
||||||
|
Plays int64
|
||||||
|
Skips int64
|
||||||
|
AvgCompletion float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||||
|
// operator can see whether each recommendation surface is landing and tune the
|
||||||
|
// taste weights. Source is stamped on play_events when a play is launched from
|
||||||
|
// a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
||||||
|
// NULL for library / radio / user-playlist plays, which are excluded here.
|
||||||
|
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||||
|
// mean completion ratio over plays that recorded one (0 when none did).
|
||||||
|
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []RecommendationSourceMetricsForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i RecommendationSourceMetricsForUserRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Source,
|
||||||
|
&i.Plays,
|
||||||
|
&i.Skips,
|
||||||
|
&i.AvgCompletion,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
@@ -91,6 +91,70 @@ func (q *Queries) InsertYouMightLikeArtist(ctx context.Context, arg InsertYouMig
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listYouMightLikeAlbumFallbackForUser = `-- name: ListYouMightLikeAlbumFallbackForUser :many
|
||||||
|
WITH liked_album_ids AS (
|
||||||
|
SELECT likb.album_id AS album_id
|
||||||
|
FROM general_likes_albums likb
|
||||||
|
WHERE likb.user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT trk.album_id AS album_id
|
||||||
|
FROM general_likes likt
|
||||||
|
JOIN tracks trk ON trk.id = likt.track_id
|
||||||
|
WHERE likt.user_id = $1
|
||||||
|
)
|
||||||
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||||
|
FROM liked_album_ids lai
|
||||||
|
JOIN albums ON albums.id = lai.album_id
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
ORDER BY md5(albums.id::text || current_date::text)
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListYouMightLikeAlbumFallbackForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListYouMightLikeAlbumFallbackForUserRow struct {
|
||||||
|
Album Album
|
||||||
|
ArtistName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS
|
||||||
|
// the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser.
|
||||||
|
func (q *Queries) ListYouMightLikeAlbumFallbackForUser(ctx context.Context, arg ListYouMightLikeAlbumFallbackForUserParams) ([]ListYouMightLikeAlbumFallbackForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listYouMightLikeAlbumFallbackForUser, arg.UserID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListYouMightLikeAlbumFallbackForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListYouMightLikeAlbumFallbackForUserRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Album.ID,
|
||||||
|
&i.Album.Title,
|
||||||
|
&i.Album.SortTitle,
|
||||||
|
&i.Album.ArtistID,
|
||||||
|
&i.Album.ReleaseDate,
|
||||||
|
&i.Album.Mbid,
|
||||||
|
&i.Album.CoverArtPath,
|
||||||
|
&i.Album.CreatedAt,
|
||||||
|
&i.Album.UpdatedAt,
|
||||||
|
&i.Album.CoverArtSource,
|
||||||
|
&i.Album.CoverArtSourcesVersion,
|
||||||
|
&i.ArtistName,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many
|
const listYouMightLikeAlbumsForUser = `-- name: ListYouMightLikeAlbumsForUser :many
|
||||||
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
SELECT albums.id, albums.title, albums.sort_title, albums.artist_id, albums.release_date, albums.mbid, albums.cover_art_path, albums.created_at, albums.updated_at, albums.cover_art_source, albums.cover_art_sources_version, artists.name AS artist_name
|
||||||
FROM you_might_like_albums yml
|
FROM you_might_like_albums yml
|
||||||
@@ -149,6 +213,90 @@ func (q *Queries) ListYouMightLikeAlbumsForUser(ctx context.Context, arg ListYou
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listYouMightLikeArtistFallbackForUser = `-- name: ListYouMightLikeArtistFallbackForUser :many
|
||||||
|
WITH liked_artist_ids AS (
|
||||||
|
SELECT lika.artist_id AS artist_id
|
||||||
|
FROM general_likes_artists lika
|
||||||
|
WHERE lika.user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT alb.artist_id AS artist_id
|
||||||
|
FROM general_likes_albums likb
|
||||||
|
JOIN albums alb ON alb.id = likb.album_id
|
||||||
|
WHERE likb.user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT trk.artist_id AS artist_id
|
||||||
|
FROM general_likes likt
|
||||||
|
JOIN tracks trk ON trk.id = likt.track_id
|
||||||
|
WHERE likt.user_id = $1
|
||||||
|
)
|
||||||
|
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||||
|
cov.id AS cover_album_id,
|
||||||
|
cnt.album_count::bigint AS album_count
|
||||||
|
FROM liked_artist_ids lai
|
||||||
|
JOIN artists ON artists.id = lai.artist_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id FROM albums
|
||||||
|
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
) cov ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT count(*) AS album_count
|
||||||
|
FROM albums WHERE artist_id = artists.id
|
||||||
|
) cnt ON true
|
||||||
|
ORDER BY md5(artists.id::text || current_date::text)
|
||||||
|
LIMIT $2
|
||||||
|
`
|
||||||
|
|
||||||
|
type ListYouMightLikeArtistFallbackForUserParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Limit int32
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListYouMightLikeArtistFallbackForUserRow struct {
|
||||||
|
Artist Artist
|
||||||
|
CoverAlbumID pgtype.UUID
|
||||||
|
AlbumCount int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broad "artists you've shown affinity for" pool to fill a thin
|
||||||
|
// You-might-like artists row: explicitly liked artists PLUS the artists of
|
||||||
|
// liked albums and liked tracks. Most users like albums/tracks far more than
|
||||||
|
// they like artists explicitly, so the union is much deeper than
|
||||||
|
// general_likes_artists alone. Daily-stable order; same projection as
|
||||||
|
// ListYouMightLikeArtistsForUser so the Go layer reuses it directly.
|
||||||
|
func (q *Queries) ListYouMightLikeArtistFallbackForUser(ctx context.Context, arg ListYouMightLikeArtistFallbackForUserParams) ([]ListYouMightLikeArtistFallbackForUserRow, error) {
|
||||||
|
rows, err := q.db.Query(ctx, listYouMightLikeArtistFallbackForUser, arg.UserID, arg.Limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var items []ListYouMightLikeArtistFallbackForUserRow
|
||||||
|
for rows.Next() {
|
||||||
|
var i ListYouMightLikeArtistFallbackForUserRow
|
||||||
|
if err := rows.Scan(
|
||||||
|
&i.Artist.ID,
|
||||||
|
&i.Artist.Name,
|
||||||
|
&i.Artist.SortName,
|
||||||
|
&i.Artist.Mbid,
|
||||||
|
&i.Artist.CreatedAt,
|
||||||
|
&i.Artist.UpdatedAt,
|
||||||
|
&i.Artist.ArtistThumbPath,
|
||||||
|
&i.Artist.ArtistFanartPath,
|
||||||
|
&i.Artist.ArtistArtSource,
|
||||||
|
&i.Artist.ArtistArtSourcesVersion,
|
||||||
|
&i.CoverAlbumID,
|
||||||
|
&i.AlbumCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items = append(items, i)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
const listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many
|
const listYouMightLikeArtistsForUser = `-- name: ListYouMightLikeArtistsForUser :many
|
||||||
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
SELECT artists.id, artists.name, artists.sort_name, artists.mbid, artists.created_at, artists.updated_at, artists.artist_thumb_path, artists.artist_fanart_path, artists.artist_art_source, artists.artist_art_sources_version,
|
||||||
cov.id AS cover_album_id,
|
cov.id AS cover_album_id,
|
||||||
|
|||||||
@@ -33,10 +33,14 @@ WHERE t.id <> $2
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- name: LoadRadioCandidatesV2 :many
|
-- name: LoadRadioCandidatesV2 :many
|
||||||
-- M4c: similarity-driven candidate pool. 5-way UNION:
|
-- M4c: similarity-driven candidate pool. 6-way UNION:
|
||||||
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
-- $1 user_id, $2 seed_track_id, $3 recently_played_hours,
|
||||||
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
-- $4 exclude (uuid[]), $5 lb_similar K, $6 similar_artists K,
|
||||||
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K.
|
-- $7 tag_overlap K, $8 likes_overlap K, $9 random_fill K,
|
||||||
|
-- $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).
|
||||||
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
-- Returns same shape as LoadRadioCandidates plus similarity_score column.
|
||||||
|
|
||||||
WITH
|
WITH
|
||||||
@@ -109,6 +113,17 @@ likes_overlap AS (
|
|||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $8
|
LIMIT $8
|
||||||
),
|
),
|
||||||
|
taste_overlap AS (
|
||||||
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
|
FROM taste_profile_artists tpa
|
||||||
|
JOIN tracks t ON t.artist_id = tpa.artist_id
|
||||||
|
WHERE tpa.user_id = $1
|
||||||
|
AND tpa.weight > 0
|
||||||
|
AND t.id NOT IN (SELECT id FROM excluded_ids)
|
||||||
|
AND t.id <> $2
|
||||||
|
ORDER BY tpa.weight DESC, t.id
|
||||||
|
LIMIT $10
|
||||||
|
),
|
||||||
random_fill AS (
|
random_fill AS (
|
||||||
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
SELECT t.id AS track_id, 0.0::float8 AS sim_score
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
@@ -119,6 +134,7 @@ random_fill AS (
|
|||||||
UNION SELECT track_id FROM similar_artists
|
UNION SELECT track_id FROM similar_artists
|
||||||
UNION SELECT track_id FROM tag_overlap
|
UNION SELECT track_id FROM tag_overlap
|
||||||
UNION SELECT track_id FROM likes_overlap
|
UNION SELECT track_id FROM likes_overlap
|
||||||
|
UNION SELECT track_id FROM taste_overlap
|
||||||
)
|
)
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT $9
|
LIMIT $9
|
||||||
@@ -135,6 +151,7 @@ FROM (
|
|||||||
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
UNION ALL SELECT track_id, sim_score FROM similar_artists
|
||||||
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
UNION ALL SELECT track_id, sim_score FROM tag_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
UNION ALL SELECT track_id, sim_score FROM likes_overlap
|
||||||
|
UNION ALL SELECT track_id, sim_score FROM taste_overlap
|
||||||
UNION ALL SELECT track_id, sim_score FROM random_fill
|
UNION ALL SELECT track_id, sim_score FROM random_fill
|
||||||
) u
|
) u
|
||||||
JOIN tracks t ON t.id = u.track_id
|
JOIN tracks t ON t.id = u.track_id
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- Recommendation observability (#796 phase 4). Per-source play outcomes so the
|
||||||
|
-- operator can see whether each recommendation surface is landing and tune the
|
||||||
|
-- taste weights. Source is stamped on play_events when a play is launched from
|
||||||
|
-- a system-playlist surface ('for_you' | 'discover' | the discovery mixes);
|
||||||
|
-- NULL for library / radio / user-playlist plays, which are excluded here.
|
||||||
|
|
||||||
|
-- name: RecommendationSourceMetricsForUser :many
|
||||||
|
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||||
|
-- mean completion ratio over plays that recorded one (0 when none did).
|
||||||
|
SELECT
|
||||||
|
pe.source,
|
||||||
|
count(*)::bigint AS plays,
|
||||||
|
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||||
|
COALESCE(
|
||||||
|
avg(pe.completion_ratio) FILTER (WHERE pe.completion_ratio IS NOT NULL),
|
||||||
|
0)::float8 AS avg_completion
|
||||||
|
FROM play_events pe
|
||||||
|
WHERE pe.user_id = $1
|
||||||
|
AND pe.source IS NOT NULL
|
||||||
|
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||||
|
GROUP BY pe.source
|
||||||
|
ORDER BY plays DESC;
|
||||||
@@ -64,3 +64,62 @@ LEFT JOIN LATERAL (
|
|||||||
WHERE yml.user_id = $1
|
WHERE yml.user_id = $1
|
||||||
ORDER BY yml.rank
|
ORDER BY yml.rank
|
||||||
LIMIT $2;
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- name: ListYouMightLikeArtistFallbackForUser :many
|
||||||
|
-- Broad "artists you've shown affinity for" pool to fill a thin
|
||||||
|
-- You-might-like artists row: explicitly liked artists PLUS the artists of
|
||||||
|
-- liked albums and liked tracks. Most users like albums/tracks far more than
|
||||||
|
-- they like artists explicitly, so the union is much deeper than
|
||||||
|
-- general_likes_artists alone. Daily-stable order; same projection as
|
||||||
|
-- ListYouMightLikeArtistsForUser so the Go layer reuses it directly.
|
||||||
|
WITH liked_artist_ids AS (
|
||||||
|
SELECT lika.artist_id AS artist_id
|
||||||
|
FROM general_likes_artists lika
|
||||||
|
WHERE lika.user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT alb.artist_id AS artist_id
|
||||||
|
FROM general_likes_albums likb
|
||||||
|
JOIN albums alb ON alb.id = likb.album_id
|
||||||
|
WHERE likb.user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT trk.artist_id AS artist_id
|
||||||
|
FROM general_likes likt
|
||||||
|
JOIN tracks trk ON trk.id = likt.track_id
|
||||||
|
WHERE likt.user_id = $1
|
||||||
|
)
|
||||||
|
SELECT sqlc.embed(artists),
|
||||||
|
cov.id AS cover_album_id,
|
||||||
|
cnt.album_count::bigint AS album_count
|
||||||
|
FROM liked_artist_ids lai
|
||||||
|
JOIN artists ON artists.id = lai.artist_id
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT id FROM albums
|
||||||
|
WHERE artist_id = artists.id AND cover_art_path IS NOT NULL
|
||||||
|
ORDER BY created_at DESC LIMIT 1
|
||||||
|
) cov ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT count(*) AS album_count
|
||||||
|
FROM albums WHERE artist_id = artists.id
|
||||||
|
) cnt ON true
|
||||||
|
ORDER BY md5(artists.id::text || current_date::text)
|
||||||
|
LIMIT $2;
|
||||||
|
|
||||||
|
-- name: ListYouMightLikeAlbumFallbackForUser :many
|
||||||
|
-- Broad "albums you've shown affinity for" pool: explicitly liked albums PLUS
|
||||||
|
-- the albums of liked tracks. Same projection as ListYouMightLikeAlbumsForUser.
|
||||||
|
WITH liked_album_ids AS (
|
||||||
|
SELECT likb.album_id AS album_id
|
||||||
|
FROM general_likes_albums likb
|
||||||
|
WHERE likb.user_id = $1
|
||||||
|
UNION
|
||||||
|
SELECT trk.album_id AS album_id
|
||||||
|
FROM general_likes likt
|
||||||
|
JOIN tracks trk ON trk.id = likt.track_id
|
||||||
|
WHERE likt.user_id = $1
|
||||||
|
)
|
||||||
|
SELECT sqlc.embed(albums), artists.name AS artist_name
|
||||||
|
FROM liked_album_ids lai
|
||||||
|
JOIN albums ON albums.id = lai.album_id
|
||||||
|
JOIN artists ON artists.id = albums.artist_id
|
||||||
|
ORDER BY md5(albums.id::text || current_date::text)
|
||||||
|
LIMIT $2;
|
||||||
|
|||||||
@@ -329,6 +329,10 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
|
|||||||
TagOverlap: 60,
|
TagOverlap: 60,
|
||||||
LikesOverlap: 40,
|
LikesOverlap: 40,
|
||||||
RandomFill: 150,
|
RandomFill: 150,
|
||||||
|
// For-You / You-might-like are the taste-driven surfaces, so pull a
|
||||||
|
// 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,9 +89,15 @@ func buildYouMightLike(
|
|||||||
}
|
}
|
||||||
|
|
||||||
zeroVec := recommendation.SessionVector{Seed: true}
|
zeroVec := recommendation.SessionVector{Seed: true}
|
||||||
|
// You-might-like surfaces in-library artists the user does NOT actively
|
||||||
|
// engage with, so it deliberately skips the taste_overlap arm — that arm
|
||||||
|
// pulls top-taste (mostly already-played) artists, which would crowd the
|
||||||
|
// pool with entities the read-time dedup then strips. For-You/radio keep it.
|
||||||
|
ymlLimits := systemForYouSourceLimits()
|
||||||
|
ymlLimits.TasteOverlap = 0
|
||||||
cands, err := recommendation.LoadCandidatesFromSimilarity(
|
cands, err := recommendation.LoadCandidatesFromSimilarity(
|
||||||
ctx, q, userID, seed, 1, zeroVec,
|
ctx, q, userID, seed, 1, zeroVec,
|
||||||
[]pgtype.UUID{seed}, systemForYouSourceLimits(),
|
[]pgtype.UUID{seed}, ymlLimits,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("you-might-like: candidate load failed; skipping",
|
logger.Warn("you-might-like: candidate load failed; skipping",
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ type CandidateSourceLimits struct {
|
|||||||
TagOverlap int
|
TagOverlap int
|
||||||
LikesOverlap int
|
LikesOverlap int
|
||||||
RandomFill int
|
RandomFill int
|
||||||
|
// TasteOverlap (#796 phase 2b): tracks by the user's top positively-
|
||||||
|
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
|
||||||
|
// users have an empty profile, so it contributes nothing anyway).
|
||||||
|
TasteOverlap int
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
|
||||||
@@ -82,6 +86,7 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
|||||||
TagOverlap: 20,
|
TagOverlap: 20,
|
||||||
LikesOverlap: 20,
|
LikesOverlap: 20,
|
||||||
RandomFill: 30,
|
RandomFill: 30,
|
||||||
|
TasteOverlap: 20,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +118,7 @@ func LoadCandidatesFromSimilarity(
|
|||||||
Limit_3: int32(limits.TagOverlap),
|
Limit_3: int32(limits.TagOverlap),
|
||||||
Limit_4: int32(limits.LikesOverlap),
|
Limit_4: int32(limits.LikesOverlap),
|
||||||
Limit_5: int32(limits.RandomFill),
|
Limit_5: int32(limits.RandomFill),
|
||||||
|
Limit_6: int32(limits.TasteOverlap),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -262,6 +262,61 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLoadCandidatesFromSimilarity_TasteOverlapArm (#796 phase 2b): a track by
|
||||||
|
// a positively-weighted taste-profile artist enters the pool via taste_overlap
|
||||||
|
// even with every other arm disabled; a negatively-weighted artist's track does
|
||||||
|
// not (the WHERE weight > 0 filter).
|
||||||
|
func TestLoadCandidatesFromSimilarity_TasteOverlapArm(t *testing.T) {
|
||||||
|
f := newFixture(t, 2) // seed + 1 other, both by the fixture artist
|
||||||
|
seed := f.tracks[0]
|
||||||
|
target := f.tracks[1]
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Fixture artist gets a positive taste weight.
|
||||||
|
if _, err := f.pool.Exec(ctx,
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, 5.0)`,
|
||||||
|
f.user, seed.ArtistID); err != nil {
|
||||||
|
t.Fatalf("insert taste (positive): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second artist with a NEGATIVE weight — its track must be excluded.
|
||||||
|
negArtist, _ := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: "NegArtist", SortName: "NegArtist"})
|
||||||
|
negAlbum, _ := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "NegAlbum", SortTitle: "NegAlbum", ArtistID: negArtist.ID})
|
||||||
|
negTrack, _ := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
||||||
|
Title: "NegTrack", AlbumID: negAlbum.ID, ArtistID: negArtist.ID,
|
||||||
|
FilePath: "/tmp/neg.flac", DurationMs: 180_000,
|
||||||
|
})
|
||||||
|
if _, err := f.pool.Exec(ctx,
|
||||||
|
`INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, -3.0)`,
|
||||||
|
f.user, negArtist.ID); err != nil {
|
||||||
|
t.Fatalf("insert taste (negative): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the taste_overlap arm is enabled.
|
||||||
|
limits := CandidateSourceLimits{TasteOverlap: 10}
|
||||||
|
got, err := LoadCandidatesFromSimilarity(
|
||||||
|
ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, limits,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
var sawTarget, sawNeg bool
|
||||||
|
for _, c := range got {
|
||||||
|
switch c.Track.ID {
|
||||||
|
case target.ID:
|
||||||
|
sawTarget = true
|
||||||
|
case negTrack.ID:
|
||||||
|
sawNeg = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sawTarget {
|
||||||
|
t.Error("positive-taste-artist track missing (taste_overlap arm didn't contribute)")
|
||||||
|
}
|
||||||
|
if sawNeg {
|
||||||
|
t.Error("negative-taste-artist track present (weight > 0 filter failed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
|
func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
|
||||||
f := newFixture(t, 1) // just the seed
|
f := newFixture(t, 1) // just the seed
|
||||||
seed := f.tracks[0]
|
seed := f.tracks[0]
|
||||||
|
|||||||
@@ -199,11 +199,12 @@ func HomeData(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID) (*Hom
|
|||||||
const youMightLikeFallbackFetch = 100
|
const youMightLikeFallbackFetch = 100
|
||||||
|
|
||||||
// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
|
// topUpYouMightLikeArtists fills a thin You-might-like artist row from the
|
||||||
// user's liked artists (daily-stable sample), excluding what's already shown
|
// artists the user has shown affinity for — explicit artist-likes PLUS the
|
||||||
// on Home (the section itself, Rediscover, Most/Last Played) so the fallback
|
// artists of liked albums and liked tracks (most users like albums/tracks far
|
||||||
// neither duplicates a tile nor suggests an actively-played artist. The liked
|
// more than artists, so the broad pool is what actually fills the row).
|
||||||
// pool dwarfs the similarity roll-up, so the same exclusions still fill it.
|
// Excludes what's already shown on Home (the section itself, Rediscover,
|
||||||
// Best-effort: a query error leaves the section as-is.
|
// Most/Last Played) so the fallback neither duplicates a tile nor suggests an
|
||||||
|
// actively-played artist. Best-effort: a query error leaves the section as-is.
|
||||||
func topUpYouMightLikeArtists(
|
func topUpYouMightLikeArtists(
|
||||||
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
ctx context.Context, q *dbq.Queries, userID pgtype.UUID, out *HomePayload,
|
||||||
) []dbq.ListYouMightLikeArtistsForUserRow {
|
) []dbq.ListYouMightLikeArtistsForUserRow {
|
||||||
@@ -218,7 +219,7 @@ func topUpYouMightLikeArtists(
|
|||||||
for _, r := range result {
|
for _, r := range result {
|
||||||
excluded[r.Artist.ID] = struct{}{}
|
excluded[r.Artist.ID] = struct{}{}
|
||||||
}
|
}
|
||||||
fb, err := q.ListRediscoverArtistsFallbackForUser(ctx, dbq.ListRediscoverArtistsFallbackForUserParams{
|
fb, err := q.ListYouMightLikeArtistFallbackForUser(ctx, dbq.ListYouMightLikeArtistFallbackForUserParams{
|
||||||
UserID: userID, Limit: youMightLikeFallbackFetch,
|
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -249,7 +250,7 @@ func topUpYouMightLikeAlbums(
|
|||||||
for _, r := range result {
|
for _, r := range result {
|
||||||
excluded[r.Album.ID] = struct{}{}
|
excluded[r.Album.ID] = struct{}{}
|
||||||
}
|
}
|
||||||
fb, err := q.ListRediscoverAlbumsFallbackForUser(ctx, dbq.ListRediscoverAlbumsFallbackForUserParams{
|
fb, err := q.ListYouMightLikeAlbumFallbackForUser(ctx, dbq.ListYouMightLikeAlbumFallbackForUserParams{
|
||||||
UserID: userID, Limit: youMightLikeFallbackFetch,
|
UserID: userID, Limit: youMightLikeFallbackFetch,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -48,6 +48,40 @@ func TestHomeData_YouMightLike_FallbackFillsFromLikedArtists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHomeData_YouMightLike_FallbackFromLikedTrackArtists: the broad fallback
|
||||||
|
// must surface the artists of LIKED TRACKS even with no explicit artist-likes
|
||||||
|
// (most users like tracks/albums, rarely artists). 12 liked tracks by distinct
|
||||||
|
// artists, no artist-likes, no plays → the artist row fills from them.
|
||||||
|
func TestHomeData_YouMightLike_FallbackFromLikedTrackArtists(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
user := seedUser(t, pool, "home-yml-trackartists")
|
||||||
|
fromLiked := make(map[pgtype.UUID]bool, 12)
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
a := seedArtist(t, pool, fmt.Sprintf("TrkArtist%02d", i), "")
|
||||||
|
al := seedAlbumForArtist(t, pool, a.ID, fmt.Sprintf("Alb%02d", i))
|
||||||
|
tr := seedTrackOnAlbum(t, pool, al.ID, a.ID, fmt.Sprintf("Trk%02d", i))
|
||||||
|
if _, err := pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, user.ID, tr.ID); err != nil {
|
||||||
|
t.Fatalf("like track: %v", err)
|
||||||
|
}
|
||||||
|
fromLiked[a.ID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := HomeData(context.Background(), pool, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("HomeData: %v", err)
|
||||||
|
}
|
||||||
|
if len(got.YouMightLikeArtists) != HomeYouMightLikeLimit {
|
||||||
|
t.Fatalf("you-might-like artists = %d, want %d (from liked-track artists)",
|
||||||
|
len(got.YouMightLikeArtists), HomeYouMightLikeLimit)
|
||||||
|
}
|
||||||
|
for _, r := range got.YouMightLikeArtists {
|
||||||
|
if !fromLiked[r.Artist.ID] {
|
||||||
|
t.Errorf("artist %s not from the liked-track pool", r.Artist.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
|
func TestHomeData_NewUser_AllSectionsEmpty(t *testing.T) {
|
||||||
pool := newPool(t)
|
pool := newPool(t)
|
||||||
user := seedUser(t, pool, "home-newuser")
|
user := seedUser(t, pool, "home-newuser")
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createQuery } from '@tanstack/svelte-query';
|
||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
// Mirrors internal/api/me_recommendation_metrics.go.
|
||||||
|
export type RecommendationMetric = {
|
||||||
|
source: string;
|
||||||
|
plays: number;
|
||||||
|
skips: number;
|
||||||
|
skip_rate: number;
|
||||||
|
avg_completion: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecommendationMetrics = {
|
||||||
|
window_days: number;
|
||||||
|
sources: RecommendationMetric[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getRecommendationMetrics(): Promise<RecommendationMetrics> {
|
||||||
|
return api.get<RecommendationMetrics>('/api/me/recommendation-metrics');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const REC_METRICS_QUERY_KEY = ['settings', 'recommendation-metrics'] as const;
|
||||||
|
|
||||||
|
export function createRecommendationMetricsQuery() {
|
||||||
|
return createQuery({
|
||||||
|
queryKey: REC_METRICS_QUERY_KEY,
|
||||||
|
queryFn: getRecommendationMetrics,
|
||||||
|
staleTime: 60_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Friendly labels for the system-playlist source keys (play_events.source).
|
||||||
|
const SOURCE_LABELS: Record<string, string> = {
|
||||||
|
for_you: 'For You',
|
||||||
|
discover: 'Discover',
|
||||||
|
deep_cuts: 'Deep cuts',
|
||||||
|
rediscover: 'Rediscover',
|
||||||
|
new_for_you: 'New for you',
|
||||||
|
on_this_day: 'On this day',
|
||||||
|
first_listens: 'First listens',
|
||||||
|
songs_like_artist: 'Songs like…'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sourceLabel(source: string): string {
|
||||||
|
return SOURCE_LABELS[source] ?? source;
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@
|
|||||||
createEnabledMutation,
|
createEnabledMutation,
|
||||||
type LBStatus
|
type LBStatus
|
||||||
} from '$lib/api/listenbrainz';
|
} from '$lib/api/listenbrainz';
|
||||||
|
import {
|
||||||
|
createRecommendationMetricsQuery,
|
||||||
|
sourceLabel,
|
||||||
|
type RecommendationMetrics
|
||||||
|
} from '$lib/api/metrics';
|
||||||
import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte';
|
import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte';
|
||||||
import { player, setCrossfade } from '$lib/player/store.svelte';
|
import { player, setCrossfade } from '$lib/player/store.svelte';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +29,7 @@
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const status = createLBStatusQuery() as CreateQueryResult<LBStatus>;
|
const status = createLBStatusQuery() as CreateQueryResult<LBStatus>;
|
||||||
|
const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>;
|
||||||
const tokenMutation = createTokenMutation(queryClient);
|
const tokenMutation = createTokenMutation(queryClient);
|
||||||
const enabledMutation = createEnabledMutation(queryClient);
|
const enabledMutation = createEnabledMutation(queryClient);
|
||||||
|
|
||||||
@@ -267,6 +273,46 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Recommendation metrics card -->
|
||||||
|
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||||
|
<h2 class="text-lg font-semibold">Recommendation metrics</h2>
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
How plays launched from each recommendation surface land, over the last
|
||||||
|
{$metrics.data?.window_days ?? 30} days. Lower skip rate and higher average
|
||||||
|
completion mean the surface is hitting.
|
||||||
|
</p>
|
||||||
|
{#if $metrics.isPending}
|
||||||
|
<p class="text-sm text-text-secondary">Loading…</p>
|
||||||
|
{:else if $metrics.isError}
|
||||||
|
<p class="text-sm text-text-secondary">Couldn't load metrics.</p>
|
||||||
|
{:else if $metrics.data && $metrics.data.sources.length > 0}
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left text-text-secondary">
|
||||||
|
<th class="py-1 font-medium">Surface</th>
|
||||||
|
<th class="py-1 text-right font-medium">Plays</th>
|
||||||
|
<th class="py-1 text-right font-medium">Skip rate</th>
|
||||||
|
<th class="py-1 text-right font-medium">Avg completion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each $metrics.data.sources as m (m.source)}
|
||||||
|
<tr class="border-t border-border">
|
||||||
|
<td class="py-1">{sourceLabel(m.source)}</td>
|
||||||
|
<td class="py-1 text-right tabular-nums">{m.plays}</td>
|
||||||
|
<td class="py-1 text-right tabular-nums">{(m.skip_rate * 100).toFixed(0)}%</td>
|
||||||
|
<td class="py-1 text-right tabular-nums">{(m.avg_completion * 100).toFixed(0)}%</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
No recommendation plays yet. Play something from For You, Discover, or a mix.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Profile card -->
|
<!-- Profile card -->
|
||||||
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||||
<h2 class="text-lg font-semibold">Profile</h2>
|
<h2 class="text-lg font-semibold">Profile</h2>
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ vi.mock('$lib/api/listenbrainz', () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('$lib/api/metrics', () => ({
|
||||||
|
createRecommendationMetricsQuery: () => ({
|
||||||
|
subscribe: (run: (v: unknown) => void) => {
|
||||||
|
run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } });
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
sourceLabel: (s: string) => s
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
globalThis.localStorage.clear();
|
globalThis.localStorage.clear();
|
||||||
document.documentElement.removeAttribute('data-theme');
|
document.documentElement.removeAttribute('data-theme');
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ vi.mock('$lib/api/me', () => ({
|
|||||||
regenerateAPIToken: vi.fn()
|
regenerateAPIToken: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/api/metrics', () => ({
|
||||||
|
createRecommendationMetricsQuery: () => ({
|
||||||
|
subscribe: (run: (v: unknown) => void) => {
|
||||||
|
run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } });
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
sourceLabel: (s: string) => s
|
||||||
|
}));
|
||||||
|
|
||||||
import SettingsPage from './+page.svelte';
|
import SettingsPage from './+page.svelte';
|
||||||
import {
|
import {
|
||||||
createLBStatusQuery,
|
createLBStatusQuery,
|
||||||
|
|||||||
Reference in New Issue
Block a user