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:
+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))
|
||||
|
||||
Reference in New Issue
Block a user