feat(taste): device-class context conditioning — #1551
Milestone #160 Opt 3b. Adds device class as a third context axis on top of the #1531 time-of-day/weekday affinity: on the radio path, a candidate is boosted when its artist concentrates in the current (daypart × weekday × device) cell. Client-sent (client_id is opaque; no UA stored), so it's captured going forward and applies to radio only (daily mixes are cron-built with no device → stay device-agnostic). Server: - Migration 0048: play_events.device_class text NULL (no CHECK; normalized in Go — one whitelist entry per new client class, not a migration). - events.go: eventRequest.device_class + normalizeDeviceClass (whitelist → mobile/web/…, else "other", empty → NULL); threaded through both RecordPlayStartedWithSource and RecordOfflinePlay into InsertPlayEvent. - ListArtistContextPlayCountsForUser gains a current-device param; the cell FILTER adds AND ($2='' OR device_class=$2) — '' reproduces the #1531 time-only behaviour exactly (used by mixes). SessionVector.DeviceClass carries it; the radio handler derives the current device from the user's latest play (GetLatestPlayDeviceClassForUser) — request-free proxy. - No new tuning knob: device narrows the existing ContextAffinityScore (reuses context_time_weight). Clients: - web: play_started sends device_class 'web'. - android: play_started + offline replay send 'mobile' (EventsWire + PlayOfflinePayload + MutationReplayer + PlayEventsReporter). Test: LoadContextAffinity device-narrowing integration test (mobile vs web artist separation; device-agnostic parity). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user