feat: play-count read path — stitched historic-stats, playcounts, deltas

Merged
SW
4mo ago
+1469-20

What

The read path of recoupable/chat#1791 — second api slice, fulfilling the contract merged in recoupable/docs#238. Three store-served endpoints (PR 1 of the agreed 2-PR split; the write path — snapshots Workflow + backfill worker — follows):

  • GET /api/research/track/historic-stats — Spotify history now comes from the measurement store only: Apify snapshot captures + Songstats-backfilled points, labeled per point (data_source), same-date collisions resolved in favor of the apify capture, start_date/end_date honored. Unbackfilled tracks return their snapshot-only series and are enqueued to songstats_backfill_queue (rank = latest play count) — the request never blocks, and Songstats never serves the Spotify request path. Other sources delegate to the untouched passthrough with per-point provenance labels.
  • GET /api/research/playcounts?spotify_album_id — latest capture for every mapped track on an album (identifiers → songs → measurements; album name from songs.album); 404 pointing at POST /snapshots when nothing is captured yet.
  • GET /api/research/track/playcount-deltas?isrc&since[&until] — nearest-capture deltas per (platform, metric) with days + run_rate_annualized; empty deltas (not an error) on insufficient history; 404 for an unknown ISRC.

#657 lessons, applied from the start

  • OCP: both passthroughs untouched; all routing in new lib/research/playcounts/ orchestrators; handlers wire one line.
  • Parse-once zod boundary: historicStatsPayloadSchema (looseObject) validated once in the orchestrator; labelSongstatsHistoricProvenance is a pure typed transform; shape drift warns + passes through unlabeled.
  • One select per table: selectSongMeasurements gains songs[] + optional platform/metric (no sibling files); selectSongIdentifiers gains songs[]; new selectSongs / upsertSongstatsBackfillQueue follow conventions.
  • No trivial extractions: single-expression merges stay inline; buildSpotifyHistoricStat / computePlaycountDeltas are extracted because they carry real logic (dedupe, windowing, grouping) — each with direct tests.

Tests

36 new tests, every unit RED before GREEN. Full suite 604 files pass; tsc delta 0 (198 pre-existing); lint + format clean (formatted before push this time).

Verification plan (preview)

Live data exists from #657's verification (The Spins: 2 apify captures in the store). Will verify: historic-stats snapshot-only series + queue row appears; deltas between the two real captures; playcounts for K.I.D.S. (Deluxe); 400/404/401 paths; a seeded songstats-data_source row to prove the mixed-provenance stitch. Results table to follow as a comment.

Merge order

docs#238 ✅ → database#31/#32 ✅ → api#657 ✅ → this → write path (snapshots Workflow + backfill worker, needs a small database claim-RPC PR first).

🤖 Generated with Claude Code


Summary by cubic

Adds the play-count read path backed by our measurement store. Spotify historic stats are stitched from snapshots and backfill points; plus new endpoints for album playcounts and per-track deltas.

  • New Features

    • GET /api/research/track/historic-stats: Spotify history served from the store (Apify snapshots + Songstats backfill), per-point data_source, date bounds honored. Unbackfilled tracks return snapshots only and are enqueued; other sources still proxy Songstats.
    • GET /api/research/playcounts?spotify_album_id=: Latest platform-displayed playcount per mapped track on an album; 404 points to POST /api/research/snapshots when no captures exist.
    • GET /api/research/track/playcount-deltas?isrc=&since[&until]: Nearest-capture deltas per (platform, metric) with days and annualized run rate. Empty deltas on insufficient history; 404 for unknown ISRC.
  • Refactors

    • Store access: selectSong_measurements now supports song or songs[] with optional platform/metric filters; selectSong_identifiers adds songs[]; new selectSongs.
    • Backfill queue: idempotent upsert to songstats_backfill_queue with rank by latest playcount; errors are logged and never block reads.