5749f48b4a
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>
72 lines
2.1 KiB
Go
72 lines
2.1 KiB
Go
package recommendation
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
type SessionVector struct {
|
|
Seed bool `json:"seed"`
|
|
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 {
|
|
v := SessionVector{
|
|
Seed: len(priorTracks) < 3,
|
|
Artists: []string{},
|
|
Tags: map[string]int{},
|
|
RecentTrackIDs: []string{},
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, t := range priorTracks {
|
|
artistID := uuidString(t.ArtistID)
|
|
if !seen[artistID] {
|
|
seen[artistID] = true
|
|
v.Artists = append(v.Artists, artistID)
|
|
}
|
|
if t.Genre != nil && *t.Genre != "" {
|
|
for _, g := range splitGenres(*t.Genre) {
|
|
v.Tags[g]++
|
|
}
|
|
}
|
|
v.RecentTrackIDs = append(v.RecentTrackIDs, uuidString(t.ID))
|
|
}
|
|
return v
|
|
}
|
|
|
|
func uuidString(u pgtype.UUID) string {
|
|
if !u.Valid {
|
|
return ""
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// splitGenres splits a track's denormalized genre string on the common
|
|
// multi-genre delimiters (`;`, `,`) used by various tag editors. Trims
|
|
// whitespace; drops empty fragments. Strings with no delimiter come back
|
|
// as a single-element slice. Concatenated-without-separator inputs (e.g.
|
|
// "ElectronicComplextroGlitch Hop" from broken tag-editor output) cannot
|
|
// be split without a genre dictionary and stay as one opaque tag.
|
|
func splitGenres(s string) []string {
|
|
parts := strings.FieldsFunc(s, func(r rune) bool {
|
|
return r == ';' || r == ','
|
|
})
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|