Skip to content

feat: Spotify listening analytics - #8

Merged
mattDev0 merged 2 commits into
mainfrom
feat/spotify-wrapped
Sep 1, 2026
Merged

feat: Spotify listening analytics#8
mattDev0 merged 2 commits into
mainfrom
feat/spotify-wrapped

Conversation

@mattDev0

@mattDev0 mattDev0 commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Adds a listening-analytics service and a dashboard panel for it.

What Spotify can and cannot answer

Verified against the live API before building, because it shapes the whole design:

Top artists / tracks available, three fixed windows
Recently played last 50 plays only
audio-features 403 — deprecated for newer apps
recommendations 404 — removed
Listening time, per-hour/day history, monthly trends not exposed at all

So half of a "Spotify Wrapped" cannot be computed from the API. apps/spotify-service therefore builds its own history: it polls recently-played every 20 minutes and stores plays in SQLite keyed on (track_id, played_at), which is what the overlapping response windows dedupe against. Genres come from the artists endpoint and are cached, being effectively static.

The UI says this plainly rather than showing an empty chart — the patterns panel carries a note that history accumulates from when tracking started.

Backend

  • Timezone: bucketing honours SPOTIFY_UTC_OFFSET_HOURS. Spotify timestamps are UTC and "when do I listen" is meaningless in anything else. A fixed offset suits zones without DST.
  • Stable axes: hour and weekday endpoints emit every slot including empty ones, so the chart doesn't reflow as data arrives.
  • Input validation: time_range checked against an allowlist in both the service and the orchestrator proxy rather than forwarded.
  • Constant-time service-key comparison.
  • Scope logging on every token refresh. A silently narrowed scope is what left the portfolio's Spotify panel stuck on "Offline" for two weeks; this makes it visible.
  • History lives on a named volume so it survives redeploys.

The orchestrator proxies /api/spotify/** behind an allowlist, so the dashboard keeps using the existing JWT boundary. Four new security tests cover unknown resources, invalid range, invalid kind, and unauthenticated access.

Frontend

Two panels in one row — the same footprint as the deployments row. The first draft was four rows, which put a music panel above deployments in the hierarchy of a DevOps console; tabs carry the detail instead.

Charts are single-series, so one hue and no legend: the bars encode magnitude, not identity.

Worth recording from the palette validator:

--accent-primary #6366f1 ↔ --accent-secondary #8b5cf6
  ΔE 0.8 (protan) · 6.3 (normal vision)

The two design-system accents are effectively the same colour and must never encode different things in one chart. They already appear side by side elsewhere on the dashboard as decoration, which is fine — but it rules them out as a categorical pair.

The peak bar uses the lighter step plus a hover label: same series under secondary encoding, not a second category.

Resource cost

48 MiB limit, bringing declared limits to 624 MiB on an 892 MiB host with 4 GiB of swap. Measured at ~25 MiB in use.

Deploying

Needs four values in the server .envSPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REFRESH_TOKEN, SPOTIFY_SERVICE_KEY, plus SPOTIFY_UTC_OFFSET_HOURS=1. .env.example documents them and scripts/authorize.py obtains a refresh token with the required scopes.

The service will not start without them, so do not merge until they are on the host, or the deploy's health check will fail.

Verified against a live account: 50 plays captured, genres, discovery ratio and hourly buckets all returning real data.

Spotify's API cannot answer most of what a listening dashboard wants. It
exposes top artists/tracks over three fixed ranges and the most recent 50
plays, and nothing else historical: no listening time, no per-hour or
per-day breakdown, no monthly trend. audio-features and recommendations
now return 403 and 404 respectively for newer apps, so audio-derived
analysis is not available either.

This service therefore builds its own history. It polls recently-played
every 20 minutes and stores plays in SQLite keyed on (track_id,
played_at), which is what the overlapping response windows dedupe against.
Genres come from the artists endpoint and are cached, being effectively
static. Aggregates are computed in SQL rather than in the dashboard.

Details worth noting:
- Bucketing honours SPOTIFY_UTC_OFFSET_HOURS. Spotify timestamps are UTC,
  and "when do I listen" is meaningless in anything but local time. A fixed
  offset suits zones without DST.
- Hour and weekday endpoints emit every slot including empty ones, so the
  chart axis stays stable as data accumulates.
- time_range is validated against an allowlist in both the service and the
  orchestrator proxy rather than being forwarded.
- The service key is compared in constant time.
- Granted scopes are logged on each token refresh; a silently narrowed
  scope is what left the portfolio's Spotify panel showing "Offline".

The orchestrator proxies /api/spotify/** so the dashboard keeps using the
existing JWT boundary, with an allowlist of proxied resources. Play history
lives on a named volume so it survives redeploys.

Verified against a live account: 50 plays captured, genre breakdown,
discovery ratio and hourly buckets all returning sensible data.
Two panels in one row, matching the footprint of the deployments row. The
first draft ran to four rows, which put a music panel above deployments in
the visual hierarchy of a DevOps console; tabs carry the detail instead.

- Top listening: artists, tracks or genres, fixed to Spotify's four week
  window. The six month and all time rankings barely differed from it.
- Listening patterns: plays by hour or weekday, with a compact stat line
  and a note that history only accumulates from when tracking started,
  since Spotify exposes just the last 50 plays.

Charts are single series throughout, so they use one hue and carry no
legend: the bars encode magnitude, not identity. The palette validator
reports the two design tokens --accent-primary and --accent-secondary as
6.3 apart for normal vision and 0.8 under protanopia, so they must never
encode different things in the same chart. The peak bar uses the lighter
step with a hover label, which is the same series under secondary
encoding rather than a second category.

The dev server can also proxy /api/spotify straight at a local
spotify-service via SPOTIFY_PROXY_TARGET, bypassing the orchestrator.
@mattDev0
mattDev0 merged commit f80fc13 into main Sep 1, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant