Compare commits

...
89 Commits
Author SHA1 Message Date
bvandeusen 4f69c230c4 Merge pull request 'Taste-profile fidelity (M160) + Songs-like row + home polish' (#109) from dev into main
test-go / test (push) Successful in 44s
test-web / test (push) Successful in 49s
test-go / integration (push) Successful in 5m1s
android / Build + lint + test (push) Successful in 5m5s
release / Build signed APK (tag releases only) (push) Successful in 4m9s
release / Build + push container image (push) Successful in 19s
2026-07-14 13:03:12 -04:00
bvandeusenandClaude Opus 4.8 5749f48b4a feat(taste): device-class context conditioning — #1551
test-go / test (push) Successful in 45s
test-web / test (push) Successful in 52s
android / Build + lint + test (push) Successful in 4m23s
test-go / integration (push) Successful in 5m5s
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>
2026-07-14 12:47:59 -04:00
bvandeusenandClaude Opus 4.8 f0c08e7326 feat(taste): mood taste facet — #1534
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m44s
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste
facet alongside artists + genre tags + eras: signed weights over canonical
mood buckets (melancholic / energetic / chill / …) derived from a track's
enriched folksonomy tags (#1490).

- internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to
  canonical mood buckets (synonyms collapse). Imported by both the taste
  builder and the scorer so a track's mood is derived identically.
- Migration 0047: taste_profile_moods table + taste_tuning.mood_scale
  (DEFAULT 0.5).
- Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors
  EraScale); accumulate folds each play/like's mood buckets at
  base*MoodScale; persist atomic-replaces the mood rows.
- Scorer (internal/recommendation): TasteProfile gains a mood term
  (own tanh scale + additive 0.12 share, so it never weakens the existing
  signal when a track has no mood tags). Match now takes the candidate's
  mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in
  the primary similarity loader only — the near-whole-library fallback
  pool passes nil (mood → 0) to avoid a full-library tag scan.
- Tuning lab: mood_scale threaded through recsettings + admin API + web
  card ("Mood weight" row) + Go/web tests.

Coverage is partial (grows with tag enrichment; richer once Last.fm is
keyed), so mood is a supplement — neutral for tracks with no mood tags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:32:41 -04:00
bvandeusenandClaude Opus 4.8 199fec2058 feat(taste): household co-play similarity — #1533
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m48s
Milestone #160 Opt 5. A collaborative candidate arm: tracks by artists
co-played across the instance with the seed's artist.

Minstrel is a single shared-library, multi-user server (no per-user
library ACL — verified: no owner/share/group model), so the "household"
is the whole instance's user set; the rule #47 scoping is satisfied by
the shared-library boundary. Single-user servers produce no edges.

- No migration: source='user_cooccurrence' was pre-whitelisted in the
  0009 similarity CHECK from day one.
- internal/db/queries/coplay.sql: Delete + Insert artist co-play edges.
  Score = Jaccard of the two artists' distinct-player sets (controls for
  globally-popular artists); >= 2 co-players AND Jaccard >= floor kept
  (the floor also self-limits hub artists). Completed plays, 365d window.
- internal/coplay: periodic worker (6h) that atomic-replaces the
  user_cooccurrence edge set from play_events — pure local SQL, no
  external calls. Wired in main.go alongside the similarity worker.
- LoadRadioCandidatesV2: new coplay_artists arm (source='user_cooccurrence',
  seed-artist based, 0.5 damp like similar_artists) + $11 limit;
  CandidateSourceLimits.UserCoplay (default 20, For-You 40).
- Integration tests: perfect-overlap Jaccard=1.0 edge + single-user
  empty-set gate.

Device axis and AcousticBrainz (Opt 4) are separately tracked; this
closes the milestone-#160 sequential options.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 10:07:19 -04:00
bvandeusenandClaude Opus 4.8 65dd132b3d feat(taste): time-of-day / weekday context conditioning — #1531
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 42s
test-go / integration (push) Successful in 4m46s
Milestone #160 Opt 3 (temporal half). A new additive scoring term that
boosts a candidate when its artist's play history concentrates in the
CURRENT daypart × weekday-type cell, in the user's local timezone.

- Migration 0046: recommendation_weight_profiles.context_time_weight
  (per-profile scoring weight, DEFAULT 1.0).
- Query ListArtistContextPlayCountsForUser: per-artist completed-play
  counts split by the current cell (daypart night[22,5)/morning[5,12)/
  afternoon[12,17)/evening[17,22) × weekday-vs-weekend) via
  started_at AT TIME ZONE users.timezone; 365-day window, skips excluded.
- internal/recommendation/context.go: LoadContextAffinity computes each
  artist's shrunk cell-share minus the user's baseline share, clamped to
  [-1,1]; sparse artists shrink toward baseline (pseudo-count 5), unknown
  artists → 0 (cold-start neutral).
- Score() gains context_affinity_score · ContextTimeWeight; both
  candidate loaders set it per candidate.
- Tuning lab: ContextTimeWeight threaded through recsettings + admin API
  + web card ("Time-of-day weight" row) + Go/web tests. Shipped 1.0 both
  profiles (uniform start, re-bakeable).

Device-class axis deferred to #1551 (needs a client_id → device-class
mapping that doesn't exist yet).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:31:43 -04:00
bvandeusenandClaude Opus 4.8 40384cc05e feat(taste): era/decade taste facet — #1530
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m40s
Milestone #160 Opt 2 (era half). A third taste facet alongside artists
+ genre tags: signed weights over decade buckets ("1990s") derived from
albums.release_date, rebuilt daily and scored into the taste match.

- Migration 0045: taste_profile_eras table (mirrors taste_profile_tags)
  + taste_tuning.era_scale column (DEFAULT 0.5).
- Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors
  EnrichedTagScale), accumulate folds each play/like's decade at
  base*EraScale, persist atomic-replaces the era rows.
- Scorer (internal/recommendation): TasteProfile gains an era term (own
  tanh scale + additive 0.15 share so it never weakens the existing
  artist/tag signal when a track is undated); candidate queries return
  album release_date; decadeOf mirrors the builder helper.
- Tuning lab: era_scale threaded through recsettings + admin API + web
  card (auto-renders the new row) + Go/web tests.

Mood facet deferred to #1534 (partial enrichment coverage + needs
candidate-side enriched-tag loading).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:01:00 -04:00
bvandeusenandClaude Opus 4.8 40056d2e9a feat(android): admin tag-enrichment sources screen (#1521)
android / Build + lint + test (push) Successful in 3m43s
Bring the tag-sources settings surface to the Android admin, over
/api/admin/tag-sources — the operator can enable/disable each provider,
paste an API key (e.g. Last.fm), and test the connection from the phone,
mirroring the web integrations card.

New vertical stack (mirrors the AdminUsers/AdminRequests pattern):
- AdminTagSourcesApi (Retrofit, api/admin/tag-sources GET/PATCH/{id}/test)
  + UpdateTagSourceBody
- AdminTagSourceWire / list envelope / TestTagSourceWire + domain
  AdminTagSourceRef / TagSourceTestResult
- AdminTagSourcesRepository (shared Retrofit, .toDomain() at bottom)
- AdminTagSourcesViewModel (@HiltViewModel, sealed UiState, optimistic
  toggle + key-save + per-row test result, network auto-recovery)
- AdminTagSourcesScreen (MinstrelTopAppBar + PullToRefreshScaffold; per
  provider: Switch, password key field + Save, Test connection + result)
- nav route + graph registration; AdminLanding gains a "Tag sources"
  section card (count = enabled providers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 08:16:43 -04:00
bvandeusenandClaude Opus 4.8 2b3be8311a feat(tuning): expose EnrichedTagScale in the tuning lab (#1520)
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m45s
Promote the enriched-tag weight (#1490) from a taste.Config default into
the DB-backed tuning lab so operators can dial how much folksonomy tags
count vs raw ID3 genre (rule #25).

- Migration 0044: taste_tuning.enriched_tag_scale (DEFAULT 0.5, backfills
  the existing row).
- recsettings: TasteTuning gains the field; seeded/read/updated through
  reconcile + persistTaste; applyTastePatch validates it to [0,1]
  (generic non-half-life clamp) and diffTaste audits it; TasteConfig maps
  it into the profile build.
- API: tasteTuningResp exposes enriched_tag_scale.
- Web tuning card: a data-driven "Enriched tag weight" knob (0 = genre
  only). Tests: recsettings persist+range, web fixture field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:51:36 -04:00
bvandeusenandClaude Opus 4.8 c30511e71b feat(tags): MusicBrainz artist-MBID tag fallback (#1519)
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m44s
Widen keyless coverage: when a track's recording is untagged or has no
recording MBID, fall back to the artist's tags (/ws/2/artist/{mbid}
?inc=tags), down-weighted 0.6 as a coarser signal. Recording tags still
win outright when present.

- ListTracksMissingTags returns a.mbid AS artist_mbid; TrackRef gains
  ArtistMBID; the enricher threads it through.
- MB provider: recording-first, artist-fallback via a shared
  fetchEntityTags helper. A transient error at the recording step is
  returned (retry) rather than masked by the fallback.

Enricher, registry, and settings are untouched — the pluggable design
absorbs the wider lookup. Tests cover fallback-when-untagged,
artist-only-when-no-recording-MBID, and recording-preferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 07:43:22 -04:00
bvandeusenandClaude Opus 4.8 797ed1f5ad feat(web): tag enrichment sources admin card (#1490 Step 4 web)
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 40s
test-go / integration (push) Successful in 4m45s
Add a "Tag enrichment sources" card to the admin integrations page,
mirroring the cover-art providers card: per-provider enable toggle +
API-key field + Save + Test connection, over /api/admin/tag-sources.
Enabling/keying a source (e.g. pasting a Last.fm key) re-opens settled
tracks for re-enrichment via the version bump.

- admin.ts: TagProvider types + get/update/test functions +
  createTagProvidersQuery; qk.tagProviders key.
- integrations/+page.svelte: the card + local edit state, with
  MusicBrainz (keyless baseline) and Last.fm (needs a free key) notes.
- Tests: admin.tag-sources API test + integrations component tests
  (render / enable+key save / test connection), plus the mock plumbing
  the existing suite needs for the new query.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:33:21 -04:00
bvandeusenandClaude Opus 4.8 96c2eb6afb fix(dbtest): reset tag-sources settings between tests (#1490)
Add tag_provider_settings to the truncation list and reset
tag_sources_meta.current_version to 1 in ResetDB, mirroring the cover-art
settings reset. Without it the migration-seeded musicbrainz/lastfm rows
leaked across tests and desynced the enabled-set signature, so a key-only
PATCH spuriously reported version_bumped=true
(TestAdminUpdateTagSource_KeyOnlyDoesNotBump).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 22:32:51 -04:00
bvandeusen 725ddca950 Merge pull request 'Milestone 127: recommendation quality — provenance, tiered mixes, For You v2, tuning lab' (#107) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 34s
release / Build + push container image (push) Successful in 36s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m41s
2026-07-03 10:13:49 -04:00
bvandeusen d145fee35d Merge pull request 'fix(android/resilience): auto-recover failed loads on reconnect' (#106) from dev into main
android / Build + lint + test (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 4m31s
release / Build + push container image (push) Successful in 16s
2026-07-02 16:45:13 -04:00
bvandeusen 611715154b Merge pull request 'M9 diagnostics follow-ups: playback relabel, sort, connected fix, per-skip + track-identity' (#105) from dev into main
test-go / test (push) Successful in 38s
test-web / test (push) Successful in 47s
android / Build + lint + test (push) Successful in 4m18s
test-go / integration (push) Successful in 4m39s
release / Build signed APK (tag releases only) (push) Successful in 3m46s
release / Build + push container image (push) Successful in 16s
2026-06-30 19:19:15 -04:00
bvandeusen 23a82fb38d Merge pull request 'M9 — Device diagnostics & debug reporting (connectivity + UPnP desync)' (#104) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 1m38s
test-go / test (push) Successful in 35s
android / Build + lint + test (push) Successful in 4m6s
test-go / integration (push) Successful in 4m37s
test-web / test (push) Successful in 47s
2026-06-29 19:24:16 -04:00
bvandeusen 0de2437689 Merge pull request 'Image rendering + player resilience (#968, #980)' (#103) from dev into main
android / Build + lint + test (push) Successful in 4m11s
test-go / test (push) Successful in 46s
test-web / test (push) Successful in 55s
test-go / integration (push) Successful in 4m44s
release / Build signed APK (tag releases only) (push) Successful in 3m53s
release / Build + push container image (push) Successful in 1m41s
2026-06-20 20:57:39 -04:00
bvandeusen a251dce7e3 Merge pull request 'feat: scan on startup by default + README first-run walkthrough & screenshots' (#102) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 37s
release / Build + push container image (push) Successful in 1m44s
test-go / integration (push) Successful in 4m35s
2026-06-20 11:40:56 -04:00
bvandeusen 938dae7163 Merge pull request 'docs: correct README setup/OOBE + fix data-volume mount' (#101) from dev into main 2026-06-20 10:58:07 -04:00
bvandeusen 93365cb555 Merge pull request 'ci(release): bundle the latest release APK into non-tag :latest builds' (#100) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 17s
2026-06-14 22:55:23 -04:00
bvandeusen eeabdf1f2c Merge pull request 'Web UI: Most Played hover fix, narrower seek bar, Android-parity track kebab' (#99) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-web / test (push) Successful in 35s
release / Build + push container image (push) Successful in 1m50s
2026-06-14 22:26:52 -04:00
bvandeusen 3e258507bb Merge pull request 'fix(android): UPnP cast resilience — drop-suppression, session adopt, recovery hardening' (#98) from dev into main
android / Build + lint + test (push) Successful in 4m39s
release / Build signed APK (tag releases only) (push) Successful in 4m31s
release / Build + push container image (push) Successful in 15s
2026-06-12 21:25:25 -04:00
bvandeusen 9550d8daaf Merge pull request 'fix(android): hold WiFi+wake lock during UPnP cast (locked-screen poll starvation)' (#97) from dev into main
android / Build + lint + test (push) Successful in 4m5s
release / Build signed APK (tag releases only) (push) Successful in 4m4s
release / Build + push container image (push) Successful in 13s
2026-06-12 19:45:44 -04:00
bvandeusen db393bbe65 Merge PR #96: recommendation batch (You-might-like fallback + taste 2b + observability)
test-go / test (push) Successful in 41s
test-web / test (push) Successful in 43s
release / Build signed APK (tag releases only) (push) Successful in 4m2s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
2026-06-12 01:10:09 -04:00
bvandeusen 7b7bd0c3e8 Merge PR #95: You-might-like liked-entity fallback (#790)
test-go / test (push) Successful in 30s
release / Build signed APK (tag releases only) (push) Successful in 3m55s
test-go / integration (push) Successful in 4m38s
release / Build + push container image (push) Successful in 13s
2026-06-11 23:33:25 -04:00
bvandeusen 8019537b02 Merge PR #94: "You might like" web client row (#790)
test-web / test (push) Successful in 35s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
release / Build + push container image (push) Successful in 1m56s
2026-06-11 23:01:38 -04:00
bvandeusen e41d603c12 Merge PR #93: move "You might like" under system-playlists row
android / Build + lint + test (push) Successful in 4m24s
release / Build signed APK (tag releases only) (push) Successful in 4m29s
release / Build + push container image (push) Successful in 14s
2026-06-11 22:55:52 -04:00
bvandeusen a62f07bd3a Merge PR #92: "You might like" Android client row (#790)
release / Build signed APK (tag releases only) (push) Successful in 4m12s
release / Build + push container image (push) Successful in 15s
android / Build + lint + test (push) Successful in 4m19s
2026-06-11 22:36:22 -04:00
bvandeusen 3c646c6974 Merge PR #91: "You might like" rows + taste profile (learn + apply)
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m34s
release / Build signed APK (tag releases only) (push) Successful in 4m5s
release / Build + push container image (push) Successful in 15s
2026-06-11 21:41:17 -04:00
bvandeusen 9a57dc4bec Merge pull request 'Offline/playback recording robustness (contract-audit follow-ups)' (#90) from dev into main
test-go / test (push) Successful in 29s
android / Build + lint + test (push) Successful in 4m23s
test-go / integration (push) Successful in 4m25s
release / Build signed APK (tag releases only) (push) Successful in 4m20s
release / Build + push container image (push) Successful in 1m53s
2026-06-11 14:19:27 -04:00
bvandeusen f167ddfbfb Merge pull request 'fix: restore native Android play-event recording (History was empty)' (#89) from dev into main
test-go / test (push) Successful in 38s
test-go / integration (push) Successful in 4m29s
android / Build + lint + test (push) Successful in 4m42s
release / Build signed APK (tag releases only) (push) Successful in 3m59s
release / Build + push container image (push) Successful in 14s
2026-06-11 09:08:20 -04:00
bvandeusen 962b4dbc8c Merge pull request 'v2026.06.07 — Sonos: one SOAP failure no longer drops to local' (#88) from dev into main
android / Build + lint + test (push) Successful in 4m37s
release / Build signed APK (tag releases only) (push) Successful in 4m21s
release / Build + push container image (push) Successful in 15s
2026-06-07 19:14:45 -04:00
bvandeusen aa4089118e chore: configure Renovate (tuned)
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 1m34s
Activate Renovate with a tuned config: target dev, ignore retired
flutter_client/**, auto-merge GREEN patch/minor bumps, hold majors behind
dependency-dashboard approval, and group go/CI/docker/gradle/npm updates.
Throttled to a weekend schedule with prHourlyLimit 2.
2026-06-07 12:00:44 -04:00
bvandeusen 7c791dc8e4 v2026.06.06 — UPnP recovery, library watcher, artist discovery, request auto-poll (#87)
android / Build + lint + test (push) Successful in 4m7s
test-go / integration (push) Successful in 4m30s
release / Build signed APK (tag releases only) (push) Successful in 3m44s
test-go / test (push) Successful in 35s
test-web / test (push) Successful in 45s
release / Build + push container image (push) Successful in 14s
2026-06-06 23:31:27 -04:00
bvandeusen 11466e1525 Merge pull request 'Unify offline detection + offline playlist UX (NetworkStatusController)' (#86) from dev into main
release / Build + push container image (push) Successful in 13s
android / Build + lint + test (push) Successful in 3m56s
release / Build signed APK (tag releases only) (push) Successful in 3m50s
2026-06-05 13:27:36 -04:00
bvandeusen 301c3bfb86 Merge pull request 'fix(android): don't flip offline on WAN-validation flicker — trust /healthz' (#85) from dev into main
android / Build + lint + test (push) Successful in 5m6s
release / Build signed APK (tag releases only) (push) Successful in 4m42s
release / Build + push container image (push) Successful in 14s
2026-06-04 23:02:28 -04:00
bvandeusen 4d8c7d6566 Merge pull request 'fix(android): notification art uses album cover, not embedded stream tags' (#84) from dev into main
android / Build + lint + test (push) Successful in 4m1s
release / Build signed APK (tag releases only) (push) Successful in 6m12s
release / Build + push container image (push) Successful in 15s
2026-06-04 22:35:21 -04:00
bvandeusen d6e6caa223 Merge pull request 'Sonos queue resync + cold-start prefetcher gate' (#83) from dev into main
android / Build + lint + test (push) Successful in 4m19s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 13s
2026-06-04 17:36:31 -04:00
bvandeusen 8b08482d13 Merge pull request 'fix(android): hysteresis on /healthz reachable signal' (#82) from dev into main
android / Build + lint + test (push) Successful in 4m46s
release / Build signed APK (tag releases only) (push) Successful in 4m26s
release / Build + push container image (push) Successful in 13s
2026-06-04 14:23:49 -04:00
bvandeusen 7cf04fe24b Merge pull request 'Android #618 offline-mode UX + Sonos polish + server DRY' (#81) from dev into main
android / Build + lint + test (push) Successful in 4m35s
release / Build signed APK (tag releases only) (push) Successful in 4m23s
release / Build + push container image (push) Successful in 13s
2026-06-04 12:53:59 -04:00
bvandeusen 222a0ff636 Merge pull request 'dev → main: collage center-crop, server DRY, CI durability-off' (#80) from dev into main
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m27s
release / Build signed APK (tag releases only) (push) Successful in 4m1s
release / Build + push container image (push) Successful in 12s
2026-06-04 08:42:36 -04:00
bvandeusen d75c1ae37f Merge pull request 'dev → main: Android UPnP/Sonos transport parity + server stream URL extension' (#79) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-go / test (push) Successful in 30s
release / Build + push container image (push) Successful in 1m24s
android / Build + lint + test (push) Successful in 4m12s
test-go / integration (push) Successful in 9m16s
2026-06-04 08:15:15 -04:00
bvandeusen 3c4c27fb08 Merge pull request 'v2026.06.03 hotfix — Sonos cast URL + UPnP picker polish' (#78) from dev into main
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m49s
test-go / integration (push) Successful in 10m56s
release / Build signed APK (tag releases only) (push) Successful in 4m15s
release / Build + push container image (push) Successful in 17s
2026-06-03 15:30:09 -04:00
bvandeusen a62a20b599 Merge pull request 'v2026.06.03 — Media3 like button + Bluetooth/UPnP picker + system playlist daily rotation' (#77) from dev into main
release / Build signed APK (tag releases only) (push) Successful in 4m22s
test-go / integration (push) Successful in 11m14s
test-go / test (push) Successful in 30s
android / Build + lint + test (push) Successful in 4m59s
release / Build + push container image (push) Successful in 2m6s
2026-06-03 14:09:23 -04:00
bvandeusen d9b2dd957c Merge pull request 'fix(android): interceptor order — auth before baseUrl (hotfix for v2026.06.02)' (#76) from dev into main
android / Build + lint + test (push) Successful in 5m36s
release / Build signed APK (tag releases only) (push) Successful in 4m28s
release / Build + push container image (push) Successful in 2m1s
2026-06-02 22:07:32 -04:00
bvandeusen 46dcd38fd8 Merge pull request 'Drift audit 2026-06-02 — 26 findings shipped' (#75) from dev into main
test-go / test (push) Successful in 43s
test-web / test (push) Successful in 56s
release / Build signed APK (tag releases only) (push) Successful in 5m10s
release / Build + push container image (push) Successful in 24s
android / Build + lint + test (push) Successful in 5m47s
test-go / integration (push) Failing after 12m37s
2026-06-02 19:21:53 -04:00
bvandeusen 7838038047 Merge pull request 'Playback errors slice + scrubber polish + various polish' (#74) from dev into main
test-go / test (push) Successful in 39s
test-web / test (push) Successful in 51s
android / Build + lint + test (push) Successful in 4m45s
release / Build signed APK (tag releases only) (push) Successful in 5m12s
release / Build + push container image (push) Successful in 22s
test-go / integration (push) Successful in 12m7s
2026-06-02 14:14:11 -04:00
bvandeusen b64965b38d Merge pull request 'Discover artwork + Library icon + notification tap routing' (#73) from dev into main
android / Build + lint + test (push) Successful in 5m55s
release / Build signed APK (tag releases only) (push) Successful in 5m26s
release / Build + push container image (push) Successful in 16s
2026-06-02 09:58:10 -04:00
bvandeusen bf2f9f3811 Merge pull request 'Lock Android MainActivity to portrait' (#72) from dev into main
android / Build + lint + test (push) Successful in 4m2s
release / Build signed APK (tag releases only) (push) Successful in 3m56s
release / Build + push container image (push) Successful in 15s
2026-06-02 08:19:07 -04:00
bvandeusen deb726a285 Merge pull request 'Keep onPostScroll under detekt ReturnCount limit' (#71) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 10s
android / Build + lint + test (push) Successful in 4m9s
2026-06-02 08:10:12 -04:00
bvandeusen 7ede83a586 Merge pull request 'Alphabet rail page-chasing + Songs Like fix + scrubber polish' (#70) from dev into main
test-web / test (push) Successful in 37s
android / Build + lint + test (push) Failing after 1m29s
release / Build signed APK (tag releases only) (push) Successful in 3m51s
release / Build + push container image (push) Successful in 12s
2026-06-01 23:36:07 -04:00
bvandeusen 1d7b91333f Merge pull request 'Web Library: alphabet rail always shows #/A-Z/&' (#69) from dev into main
test-web / test (push) Successful in 45s
release / Build + push container image (push) Successful in 19s
release / Build signed APK (tag releases only) (push) Successful in 3m57s
2026-06-01 21:55:49 -04:00
bvandeusen 883d416d26 Merge pull request 'Web Library: continuous grid + sticky alphabet rail' (#68) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 28s
test-web / test (push) Successful in 35s
2026-06-01 21:36:55 -04:00
bvandeusen 09471a8f5c Merge pull request 'Web: Most Played horizontal tiles + nav centering + Library link + Search refinements' (#67) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 25s
test-web / test (push) Successful in 34s
2026-06-01 21:06:03 -04:00
bvandeusen 7de238e91e Merge pull request 'Web Home: drop hero row + compact Rediscover tiles' (#66) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
release / Build + push container image (push) Successful in 26s
test-web / test (push) Successful in 35s
2026-06-01 20:43:53 -04:00
bvandeusen 9440c5860b Merge pull request 'Android v1 polish + Web UI flavor pass' (#65) from dev into main
release / Build signed APK (tag releases only) (push) Has been skipped
test-web / test (push) Successful in 40s
release / Build + push container image (push) Successful in 41s
android / Build + lint + test (push) Successful in 4m29s
2026-06-01 20:17:49 -04:00
bvandeusen 082d31bfa9 Merge pull request 'ci: chain APK build → image build via needs (fix race, kill polling)' (#64) from dev into main
android / Build + lint + test (push) Successful in 5m15s
release / Build signed APK (tag releases only) (push) Successful in 4m47s
release / Build + push container image (push) Successful in 14s
2026-06-01 18:31:12 -04:00
bvandeusen 8847b43d9e Merge pull request 'ci(android): make APK attach failures visible (debug v2026.06.01 missing asset)' (#63) from dev into main
release / release (push) Successful in 11s
android / Build + lint + test (push) Successful in 4m16s
android / Build signed release APK (push) Successful in 3m9s
2026-06-01 18:13:43 -04:00
bvandeusen 09cc810e5a Merge pull request 'ci: pin upload-artifact to v3 (Gitea GHES-mode incompatible with v4)' (#62) from dev into main
release / release (push) Successful in 11s
android / Build signed release APK (push) Has been skipped
android / Build + lint + test (push) Successful in 4m34s
2026-06-01 17:38:24 -04:00
bvandeusen 2534384ed1 Merge pull request 'fix(android): remove WorkManager auto-initializer (lintVitalRelease)' (#61) from dev into main
android / Build + lint + test (push) Successful in 3m42s
android / Build signed release APK (push) Successful in 3m22s
release / release (push) Successful in 7m46s
2026-06-01 16:55:52 -04:00
bvandeusen 9ff1b30e3f Merge pull request 'fix(docker): bump builder to go 1.25 to match go.mod' (#60) from dev into main
android / Build + lint + test (push) Successful in 3m36s
android / Build signed release APK (push) Failing after 3m21s
release / release (push) Successful in 15m17s
2026-06-01 16:13:43 -04:00
bvandeusen c01853577b Merge pull request 'Release: web UX overhaul + Android native port + server polish' (#59) from dev into main
test-go / test (push) Successful in 33s
test-web / test (push) Successful in 44s
release / release (push) Failing after 15m9s
android / Build + lint + test (push) Successful in 4m49s
android / Build signed release APK (push) Failing after 3m32s
test-go / integration (push) Successful in 9m33s
2026-06-01 16:11:21 -04:00
bvandeusen 747ed4134b Merge pull request 'v2026.05.21.0 — Wear OS dispatch fix, playlist-load feedback, drift CI' (#58) from dev into main 2026-05-21 15:42:06 -04:00
bvandeusen ccbd3b62a0 Merge pull request 'v2026.05.19.3 — playback stall resilience + legacy home cleanup' (#56) from dev into main 2026-05-19 15:48:08 -04:00
bvandeusen 19de0c2874 Merge pull request 'v2026.05.19.2 — hotfix: restore media notification (remove broken custom favorite)' (#55) from dev into main
Merge v2026.05.19.2 hotfix — restore media notification (PR #55)
2026-05-19 07:48:10 -04:00
bvandeusen 22a4649bfc Merge pull request 'v2026.05.19.1 — hotfix: notification permission + full-player auto-minimize' (#54) from dev into main
Merge v2026.05.19.1 hotfix — notification permission + full-player auto-minimize (PR #54)
2026-05-18 23:08:33 -04:00
bvandeusen 8e7660c05e Merge pull request 'fix(ci): scope integration Postgres discovery to this job's network' (#53) from dev into main
Merge CI fix: scope integration Postgres discovery to this job's network (PR #53)
2026-05-18 22:50:36 -04:00
bvandeusen 53be834e89 Merge pull request 'v2026.05.19.0 — MediaSession lifecycle, Lidarr hardening, Lucide migration' (#52) from dev into main
Merge v2026.05.19.0 — MediaSession lifecycle, Lidarr hardening, Lucide migration (PR #52)
2026-05-18 21:42:48 -04:00
bvandeusen 0d410630a2 Merge pull request 'Release v2026.05.18.0 — integration-tests-in-CI + recommendations/cover-art/discover batch' (#51) from dev into main 2026-05-17 22:41:20 -04:00
bvandeusen 62db8edcdb Merge pull request 'Release v2026.05.16.0 — recommendations working end-to-end + cover-art uncap' (#50) from dev into main 2026-05-16 18:58:55 -04:00
bvandeusen ec0cc37bc9 Merge pull request 'Hotfix v2026.05.15.1 — allow discovery-mix variants in playlists CHECK constraints' (#49) from dev into main 2026-05-16 00:32:25 -04:00
bvandeusen e772938a3b Merge pull request 'Release v2026.05.15.0 — system playlists v2, offline cache rework, CI speedup' (#48) from dev into main 2026-05-15 23:15:48 -04:00
bvandeusen 29fee5aa37 Merge pull request 'Release v2026.05.14.0 — player polish, CacheFiller, offline mutation queue' (#47) from dev into main 2026-05-15 01:17:44 +00:00
bvandeusen e5ab471ce1 Merge pull request 'release v2026.05.13.3: full-player seed + MediaSession expansion (Wear)' (#46) from dev into main 2026-05-14 18:19:39 +00:00
bvandeusen 573aa4226d Merge pull request 'release v2026.05.13.2: artist covers + load-then-swap player transitions' (#45) from dev into main 2026-05-14 16:31:43 +00:00
bvandeusen 7339815ea9 Merge pull request 'release v2026.05.13.1: player + Discover hotfix' (#44) from dev into main 2026-05-14 15:17:45 +00:00
bvandeusen baa601765e Merge pull request 'release v2026.05.13.0: SSE live updates + offline cache + per-item rendering' (#43) from dev into main 2026-05-14 02:36:47 +00:00
bvandeusen 0d009b34e2 Merge pull request 'release v2026.05.12.1: Discover surface + nav fixes + cache hygiene' (#42) from dev into main 2026-05-12 03:58:43 +00:00
bvandeusen f1b4652c77 Merge pull request 'release v2026.05.11.3: caching, perf, playlist polish' (#41) from dev into main 2026-05-12 00:35:48 +00:00
bvandeusen e610948307 Merge pull request 'release v2026.05.11.2: signing key + library infinite scroll' (#40) from dev into main 2026-05-11 19:51:41 +00:00
bvandeusen fb811804d2 Merge pull request 'release v2026.05.11.1: Flutter caching, navigation, and player polish' (#39) from dev into main 2026-05-11 17:47:41 +00:00
bvandeusen 37134950a5 Merge pull request 'release: M5-M7 server + web + Flutter, plus Flutter v1 polish' (#38) from dev into main 2026-05-11 15:21:58 +00:00
bvandeusen fcded9294c Merge pull request 'fix(web): download button + server version display' (#37) from dev into main 2026-05-11 03:20:39 +00:00
bvandeusen 42abb7adff Merge pull request 'ci: unify REGISTRY_TOKEN + RELEASE_TOKEN into CI_TOKEN' (#36) from dev into main 2026-05-11 01:45:01 +00:00
bvandeusen 04933f2d9f Merge pull request 'M5–M7 sweep + offline cache + in-app updates + DRY pass' (#35) from dev into main 2026-05-11 01:09:36 +00:00
bvandeusen 626fc7502c Merge pull request 'refactor(server): remove bootstrap admin path' (#34) from dev into main 2026-05-09 02:19:40 +00:00
bvandeusen 9bf3b8a2f2 Merge pull request 'feat(flutter): admin parity slice — requests, quarantine, users, invites' (#33) from dev into main 2026-05-09 02:07:59 +00:00
bvandeusen 492460cf4a Merge pull request 'fix(web): /register reachable for bootstrap admin (closes #376)' (#32) from dev into main 2026-05-08 22:01:37 +00:00
bvandeusen 1c775905d7 Release v2026.05.08.1 — DRY pass + cover-art HTTP base (#31)
Merges ~50 commits from dev: full DRY pass round 1 + round 2, plus PR3 cover-art HTTP base.
2026-05-08 17:45:41 +00:00
bvandeusen cd2ff648d0 Merge pull request 'M5a frontend + M5b quarantine + M5c suggestions' (#30) from dev into main 2026-05-01 11:14:18 +00:00
74 changed files with 3159 additions and 255 deletions
@@ -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,
)
}
}
}
}
@@ -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 },
)
}
}
@@ -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,
)
@@ -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,
)
/**
@@ -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
+7
View File
@@ -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 artistartist
// 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
+24 -16
View File
@@ -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
View File
@@ -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))
+22
View File
@@ -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.
+86
View File
@@ -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 artistartist 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
}
+162
View File
@@ -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)
}
}
+78
View File
@@ -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
// artistartist 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
}
+21 -13
View File
@@ -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
View File
@@ -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 {
+138 -3
View File
@@ -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 67 (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
+65 -35
View File
@@ -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
}
+154 -14
View File
@@ -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
+39 -2
View File
@@ -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;
+55
View File
@@ -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
-- artistartist 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;
+4 -2
View File
@@ -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
+92 -3
View File
@@ -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 67 (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
+19 -13
View File
@@ -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 *;
+39 -5
View File
@@ -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;
+11 -2
View File
@@ -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[]);
+12
View File
@@ -62,6 +62,10 @@ var dataTables = []string{
// (never recreates the singleton, seeded once by 0018); ResetDB
// resets its counter via UPDATE below instead.
"cover_art_provider_settings",
// Same reasoning for the tag-enrichment settings (#1490): truncate the
// per-provider rows (reconcile re-seeds registered ones), and reset the
// tag_sources_meta counter via UPDATE below rather than truncating it.
"tag_provider_settings",
// recsettings.New reconciles shipped defaults on every construction
// (#1250), so truncating gives each test pristine tuning values.
"recommendation_weight_profiles",
@@ -98,4 +102,12 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset cover-art version: %v", err)
}
// Same for the tag-sources counter (#1490) — clears cross-test version
// accumulation that would spuriously report version_bumped on a
// key-only change.
if _, err := pool.Exec(ctx,
"UPDATE tag_sources_meta SET current_version = 1, last_registered_providers_hash = ''",
); err != nil {
t.Fatalf("dbtest.ResetDB reset tag-sources version: %v", err)
}
}
+58
View File
@@ -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
}
+43
View File
@@ -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)
}
}
+28 -17
View File
@@ -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
+9 -9
View File
@@ -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)
}
+6
View File
@@ -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,
}
}
+61 -2
View File
@@ -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
+75
View File
@@ -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))
}
}
+57
View File
@@ -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)
}
}
+17 -8
View File
@@ -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
}
+5
View File
@@ -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 {
+70 -8
View File
@@ -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].
+60 -8
View File
@@ -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)
}
}
+13
View File
@@ -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
}
+70 -43
View File
@@ -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,
}
}
+61
View File
@@ -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)
+3
View File
@@ -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)
+4 -1
View File
@@ -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
+49 -14
View File
@@ -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
+87 -1
View File
@@ -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
View File
@@ -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)
}
+61
View File
@@ -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)"
}
]
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
getTagProviders,
updateTagProvider,
testTagProvider,
type TagProvider,
type TagProvidersResponse
} from './admin';
vi.mock('./client', () => ({
api: { get: vi.fn(), post: vi.fn(), patch: vi.fn() }
}));
import { api } from './client';
describe('admin tag-sources API', () => {
beforeEach(() => vi.clearAllMocks());
it('getTagProviders GETs the correct path', async () => {
const sample: TagProvidersResponse = {
providers: [
{
id: 'musicbrainz',
display_name: 'MusicBrainz',
requires_api_key: false,
supports: ['track_tags'],
enabled: true,
api_key_set: false,
display_order: 0,
testable: true
}
],
sources_version: 1
};
(api.get as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(sample);
const got = await getTagProviders();
expect(api.get).toHaveBeenCalledWith('/api/admin/tag-sources');
expect(got.providers[0].id).toBe('musicbrainz');
expect(got.sources_version).toBe(1);
});
it('updateTagProvider PATCHes the right path with patch body', async () => {
const patched: TagProvider & { version_bumped: boolean } = {
id: 'lastfm',
display_name: 'Last.fm',
requires_api_key: true,
supports: ['track_tags'],
enabled: true,
api_key_set: true,
display_order: 1,
testable: true,
version_bumped: true
};
(api.patch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(patched);
const got = await updateTagProvider('lastfm', { enabled: true, api_key: 'mykey' });
expect(api.patch).toHaveBeenCalledWith(
'/api/admin/tag-sources/lastfm',
{ enabled: true, api_key: 'mykey' }
);
expect(got.api_key_set).toBe(true);
expect(got.version_bumped).toBe(true);
});
it('testTagProvider POSTs to /test endpoint with empty body', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
duration_ms: 55
});
const got = await testTagProvider('musicbrainz');
expect(api.post).toHaveBeenCalledWith('/api/admin/tag-sources/musicbrainz/test', {});
expect(got.ok).toBe(true);
expect(got.duration_ms).toBe(55);
});
it('testTagProvider returns ok=false with error string on failure', async () => {
(api.post as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: false,
error: 'lastfm api key not set'
});
const got = await testTagProvider('lastfm');
expect(got.ok).toBe(false);
expect(got.error).toBe('lastfm api key not set');
});
});
+61
View File
@@ -383,6 +383,67 @@ export function createCoverProvidersQuery() {
});
}
// Tag-enrichment providers -------------------------------------------------
// Parallel to the cover-art providers surface, over /api/admin/tag-sources.
// Independent settings so a new folksonomy source is added without touching
// art config (#1490).
export type TagProviderCapability = 'track_tags';
export type TagProvider = {
id: string;
display_name: string;
requires_api_key: boolean;
supports: TagProviderCapability[];
enabled: boolean;
api_key_set: boolean;
display_order: number;
testable: boolean;
};
export type TagProvidersResponse = {
providers: TagProvider[];
sources_version: number;
};
export async function getTagProviders(): Promise<TagProvidersResponse> {
return api.get<TagProvidersResponse>('/api/admin/tag-sources');
}
export type UpdateTagProviderPatch = {
enabled?: boolean;
api_key?: string;
};
export type UpdateTagProviderResponse = TagProvider & {
version_bumped: boolean;
};
export async function updateTagProvider(
id: string,
patch: UpdateTagProviderPatch
): Promise<UpdateTagProviderResponse> {
return api.patch<UpdateTagProviderResponse>(`/api/admin/tag-sources/${id}`, patch);
}
export type TestTagProviderResponse = {
ok: boolean;
duration_ms?: number;
error?: string;
};
export async function testTagProvider(id: string): Promise<TestTagProviderResponse> {
return api.post<TestTagProviderResponse>(`/api/admin/tag-sources/${id}/test`, {});
}
export function createTagProvidersQuery() {
return createQuery({
queryKey: qk.tagProviders(),
queryFn: getTagProviders,
staleTime: 60_000
});
}
// Admin user-management ------------------------------------------------------
export type AdminUser = {
+1
View File
@@ -48,6 +48,7 @@ export const qk = {
scanStatus: () => ['scanStatus'] as const,
coverage: () => ['coverage'] as const,
coverProviders: () => ['coverProviders'] as const,
tagProviders: () => ['tagProviders'] as const,
adminUsers: () => ['adminUsers'] as const,
adminInvites: () => ['adminInvites'] as const,
adminDiagnostics: (f: Record<string, string | number | undefined>) =>
+4
View File
@@ -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';
+4 -1
View File
@@ -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.
@@ -13,10 +13,14 @@
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
createTagProvidersQuery,
updateTagProvider,
testTagProvider,
updateSMTPConfig,
testSMTPConfig,
createSMTPConfigQuery,
type CoverProvider,
type TagProvider,
type SMTPConfig
} from '$lib/api/admin';
import { qk } from '$lib/api/queries';
@@ -280,6 +284,62 @@
}
}
// ---- Tag enrichment providers ----
// Mirrors the cover-art providers panel over /api/admin/tag-sources (#1490).
const tagProvidersStore = $derived(createTagProvidersQuery());
const tagProvidersQ = $derived($tagProvidersStore);
let tagLocalState = $state<Record<string, { enabled: boolean; apiKey: string }>>({});
let tagTestResults = $state<Record<string, { ok: boolean; duration_ms?: number; error?: string }>>({});
let tagSaving = $state<Record<string, boolean>>({});
let tagTesting = $state<Record<string, boolean>>({});
$effect(() => {
if (tagProvidersQ.data) {
for (const p of tagProvidersQ.data.providers) {
if (!tagLocalState[p.id]) {
tagLocalState[p.id] = { enabled: p.enabled, apiKey: '' };
}
}
}
});
function tagHasChanges(provider: TagProvider): boolean {
const local = tagLocalState[provider.id];
if (!local) return false;
return provider.enabled !== local.enabled || local.apiKey !== '';
}
async function tagSave(provider: TagProvider) {
const local = tagLocalState[provider.id];
if (!local || !tagHasChanges(provider)) return;
const patch: { enabled?: boolean; api_key?: string } = {};
if (provider.enabled !== local.enabled) patch.enabled = local.enabled;
if (local.apiKey !== '') patch.api_key = local.apiKey;
tagSaving[provider.id] = true;
try {
const result = await updateTagProvider(provider.id, patch);
tagLocalState[provider.id] = { enabled: result.enabled, apiKey: '' };
await client.invalidateQueries({ queryKey: qk.tagProviders() });
} catch (_e) {
// Inline error surfacing handled by the mutation toast layer; swallow here.
} finally {
tagSaving[provider.id] = false;
}
}
async function tagTest(provider: TagProvider) {
tagTesting[provider.id] = true;
try {
tagTestResults[provider.id] = await testTagProvider(provider.id);
} catch (e) {
tagTestResults[provider.id] = { ok: false, error: (e as Error).message };
} finally {
tagTesting[provider.id] = false;
}
}
// SMTP config -------------------------------------------------------------
const smtpStore = $derived(createSMTPConfigQuery());
@@ -597,6 +657,108 @@
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
</section>
<!-- Tag enrichment providers -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
<h3 class="font-display text-lg font-medium text-text-primary">Tag enrichment sources</h3>
<p class="mt-1 text-sm text-text-secondary">
Folksonomy style/mood tags fetched per track and folded into each
listener's taste profile alongside the raw file genre — so "Rock"
gains "post-punk / shoegaze / melancholic". All enabled sources are
merged. Enabling or disabling a source re-opens previously-processed
tracks for a fresh pass.
</p>
</div>
{#if tagProvidersQ.data}
{#each tagProvidersQ.data.providers as provider (provider.id)}
<article class="rounded-lg border border-border bg-background p-4 space-y-3">
<header class="flex items-baseline justify-between gap-3">
<div class="flex items-center gap-2">
<h4 class="font-display text-base font-medium text-text-primary">{provider.display_name}</h4>
<span
class="h-1.5 w-1.5 rounded-full {provider.enabled ? 'bg-action-primary' : 'bg-text-muted'}"
title={provider.enabled ? 'Active' : 'Disabled'}
></span>
</div>
<div class="flex flex-wrap gap-1">
{#each provider.supports as cap}
<span class="rounded bg-surface px-1.5 py-0.5 text-xs text-text-secondary">{cap.replace('_', ' ')}</span>
{/each}
</div>
</header>
{#if tagLocalState[provider.id]}
<div class="grid gap-2 sm:grid-cols-[auto_1fr] items-center">
<label class="text-sm text-text-secondary" for="tag-enabled-{provider.id}">Enabled</label>
<div>
<input id="tag-enabled-{provider.id}" type="checkbox"
bind:checked={tagLocalState[provider.id].enabled} />
</div>
{#if provider.requires_api_key}
<label class="text-sm text-text-secondary" for="tag-key-{provider.id}">API key</label>
<div class="flex items-center gap-2">
<input id="tag-key-{provider.id}" type="password"
placeholder={provider.api_key_set ? '••• (saved — leave empty to keep)' : 'Paste your API key to enable this source'}
bind:value={tagLocalState[provider.id].apiKey}
class="mt-1 w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent flex-1" />
{#if provider.api_key_set}
<span class="text-xs text-text-secondary whitespace-nowrap">✓ Set</span>
{/if}
</div>
{/if}
</div>
<div class="flex items-center gap-2 pt-1">
<button type="button"
disabled={!tagHasChanges(provider) || tagSaving[provider.id]}
onclick={() => tagSave(provider)}
class="inline-flex items-center gap-2 rounded-md bg-action-primary px-3 py-1.5 text-sm text-action-fg disabled:opacity-50">
{tagSaving[provider.id] ? 'Saving…' : 'Save changes'}
</button>
{#if provider.testable}
<button type="button"
disabled={tagTesting[provider.id]}
onclick={() => tagTest(provider)}
class="inline-flex items-center gap-2 rounded-md border border-border bg-transparent px-3 py-1.5 text-sm text-text-secondary hover:text-text-primary disabled:opacity-50">
{tagTesting[provider.id] ? 'Testing…' : 'Test connection'}
</button>
{/if}
{#if tagTestResults[provider.id]}
{#if tagTestResults[provider.id].ok}
<p class="text-sm text-action-primary">
OK{tagTestResults[provider.id].duration_ms ? ` (${tagTestResults[provider.id].duration_ms}ms)` : ''}
</p>
{:else}
<p class="text-sm text-error">
Failed — {tagTestResults[provider.id].error}
</p>
{/if}
{/if}
</div>
{/if}
{#if provider.id === 'musicbrainz'}
<p class="text-xs text-text-muted">
Keyless and on by default — the always-available baseline. Matches tags by
recording MBID, so coverage tracks how well your library is MusicBrainz-tagged.
</p>
{:else if provider.id === 'lastfm'}
<p class="text-xs text-text-muted">
Name-based (artist + title), so it covers tracks without an MBID. Requires a free
API key — get one at <a class="underline" href="https://www.last.fm/api/account/create" target="_blank" rel="noopener noreferrer">last.fm/api</a>.
</p>
{/if}
</article>
{/each}
{:else if tagProvidersQ.isPending}
<p class="text-sm text-text-muted">Loading…</p>
{:else}
<p class="text-sm text-text-muted">No providers registered.</p>
{/if}
</section>
<!-- SMTP / email config -->
<section class="space-y-4 rounded-xl border border-border bg-surface p-5">
<div>
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/svelte';
import { mockQuery } from '../../../test-utils/query';
import type { LidarrConfig } from '$lib/api/types';
import type { CoverProvider, SMTPConfig } from '$lib/api/admin';
import type { CoverProvider, TagProvider, SMTPConfig } from '$lib/api/admin';
// useQueryClient is wrapped so the page can call invalidateQueries() without
// a real QueryClient context. Per-file mock OVERRIDES the vitest.setup default
@@ -23,6 +23,9 @@ vi.mock('$lib/api/admin', () => ({
createCoverProvidersQuery: vi.fn(),
updateCoverProvider: vi.fn(),
testCoverProvider: vi.fn(),
createTagProvidersQuery: vi.fn(),
updateTagProvider: vi.fn(),
testTagProvider: vi.fn(),
createSMTPConfigQuery: vi.fn(),
updateSMTPConfig: vi.fn(),
testSMTPConfig: vi.fn()
@@ -39,6 +42,9 @@ import {
createCoverProvidersQuery,
updateCoverProvider,
testCoverProvider,
createTagProvidersQuery,
updateTagProvider,
testTagProvider,
createSMTPConfigQuery,
updateSMTPConfig,
testSMTPConfig
@@ -95,6 +101,34 @@ const defaultCoverProviders = {
sources_version: 1
};
// Fixture tag providers
const providerMusicbrainz: TagProvider = {
id: 'musicbrainz',
display_name: 'MusicBrainz',
requires_api_key: false,
supports: ['track_tags'],
enabled: true,
api_key_set: false,
display_order: 0,
testable: true
};
const providerLastfm: TagProvider = {
id: 'lastfm',
display_name: 'Last.fm',
requires_api_key: true,
supports: ['track_tags'],
enabled: false,
api_key_set: false,
display_order: 1,
testable: true
};
const defaultTagProviders = {
providers: [providerMusicbrainz, providerLastfm],
sources_version: 1
};
const testResponseOk = {
ok: true as const,
version: '2.0.5',
@@ -133,7 +167,7 @@ afterEach(() => {
invalidateQueries.mockReset();
});
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; smtp?: SMTPConfig } = {}) {
function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCoverProviders | null; tagProviders?: typeof defaultTagProviders | null; smtp?: SMTPConfig } = {}) {
(createLidarrConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.config ?? cfgConnected })
);
@@ -160,6 +194,10 @@ function setup(opts: { config?: LidarrConfig; coverProviders?: typeof defaultCov
(createCoverProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: cpData, isPending: opts.coverProviders === null })
);
const tpData = opts.tagProviders === null ? undefined : (opts.tagProviders ?? defaultTagProviders);
(createTagProvidersQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: tpData, isPending: opts.tagProviders === null })
);
(createSMTPConfigQuery as ReturnType<typeof vi.fn>).mockReturnValue(
mockQuery({ data: opts.smtp ?? smtpUnset })
);
@@ -195,6 +233,16 @@ function providerCard(providerName: string): HTMLElement {
.closest('article') as HTMLElement;
}
function tagProvidersSection(): HTMLElement {
return screen.getByRole('heading', { name: /tag enrichment sources/i, level: 3 }).closest('section') as HTMLElement;
}
function tagProviderCard(providerName: string): HTMLElement {
return within(tagProvidersSection())
.getByRole('heading', { name: providerName, level: 4 })
.closest('article') as HTMLElement;
}
describe('/admin/integrations', () => {
test('Save calls putLidarrConfig with empty api_key when input is blank', async () => {
setup();
@@ -641,3 +689,48 @@ describe('Lidarr first-time setup (Test-then-Save)', () => {
});
});
});
describe('/admin/integrations tag sources', () => {
test('renders tag enrichment providers', () => {
setup();
const section = tagProvidersSection();
expect(within(section).getByRole('heading', { name: 'MusicBrainz', level: 4 })).toBeInTheDocument();
expect(within(section).getByRole('heading', { name: 'Last.fm', level: 4 })).toBeInTheDocument();
expect(within(section).getAllByText('track tags').length).toBeGreaterThanOrEqual(1);
});
test('enabling Last.fm and typing a key calls updateTagProvider', async () => {
setup();
(updateTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...providerLastfm,
enabled: true,
api_key_set: true,
version_bumped: true
});
const card = tagProviderCard('Last.fm');
const enabledCheckbox = card.querySelector('input[type="checkbox"]') as HTMLInputElement;
await fireEvent.click(enabledCheckbox);
const keyInput = within(card).getByLabelText(/api key/i);
await fireEvent.input(keyInput, { target: { value: 'lfmkey' } });
await fireEvent.click(within(card).getByRole('button', { name: /save changes/i }));
expect(updateTagProvider).toHaveBeenCalledWith(
'lastfm',
expect.objectContaining({ enabled: true, api_key: 'lfmkey' })
);
await waitFor(() =>
expect(invalidateQueries).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ['tagProviders'] })
)
);
});
test('Test connection on a tag provider calls testTagProvider and shows OK', async () => {
setup();
(testTagProvider as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ok: true, duration_ms: 42 });
const card = tagProviderCard('MusicBrainz');
await fireEvent.click(within(card).getByRole('button', { name: /test connection/i }));
await waitFor(() => expect(testTagProvider).toHaveBeenCalledWith('musicbrainz'));
await waitFor(() => expect(within(card).getByText(/ok.*42ms/i)).toBeInTheDocument());
});
});
+6 -2
View File
@@ -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
});