38 KiB
Phase 16 — Anonymous Play & Share Tracking (Design Spec)
Status: design-complete — decisions D1–D7 resolved by Daniel 2026-06-19. Author:
product-designer. Drafted 2026-06-18; decisions resolved and phasing re-sequenced 2026-06-19.
No code has been written by this doc. This is the phase deferred behind the home-hero "Plays"
stat card, which today renders a static XXX / Plays (Coming Soon) odometer placeholder in
NowPlayingStats.razor.
Phasing note (Daniel directive, 2026-06-19): the waves run bottom-up — foundation first, metrics stacked on the substrate, the user-visible Plays-card flip is the capstone (built last). This deliberately reverses the earlier "visible win comes early" framing: Daniel does not care about the live card until everything underneath it is finished, so the whole telemetry substrate and all metrics (including unique listeners) land before the card lights up. See §6.
This spec adds a privacy-light, anonymous play & share telemetry layer to the public site: counting plays (bucketed by completion) and shares, tied to individual tracks and releases, with an optional unique-listener "plus" metric. It does not add accounts, PII, or any per-user identity model — that is a hard constraint, not a deferral.
Phase numbering
This is Phase 16. Phase 15 (Visualizer Controls Enhancements) is the highest-numbered phase in
PLAN.md. Phases 11 and 10-Reframe are landed; no phase 16 exists yet. If a concurrent worktree has
claimed 16 by the time this is scoped, bump to the next free number — the content is
number-independent.
Cross-references (read these before implementing)
DeepDrftPublic.Client/Services/StreamingAudioPlayerService.cs— the production player. The instrumentation seam lives here:LoadTrackStreaming(track-load = play-start candidate), the progress callback path, andResetToIdle(stop/unload/switch)._currentTrackIdholds the currentEntryKey. No release id is currently held by the player — see §2.3.DeepDrftPublic.Client/Services/AudioPlayerService.cs— base class.OnProgressCallback(double currentTime)is the per-tick position seam;OnPlaybackEndCallbackis the organic end-of-stream seam (and the only placeTrackEndedfires).Durationis set from the WAV header during load.DeepDrftPublic.Client/Services/AudioInteropService.cs—SetOnProgressCallbackAsync/SetOnEndCallbackAsyncare the JS→.NET callbacks already wired inInitializeAsync. Progress is throttled to ~10/sec on the JS side already.DeepDrftPublic.Client/Services/QueueService.cs— auto-advance orchestrator. Album playthroughs flowPlayRelease → PlayCurrent → SelectTrackStreamingper track;OnTrackEndedadvances. Every track in an album play is an independentSelectTrackStreamingcall, so per-track play events arise naturally without queue-specific instrumentation.DeepDrftPublic.Client/Controls/SharePopover.razor[.cs]— the real share surface. Two share actions exist today: Copy link (track mode + release mode) and Copy embed (track mode only, an<iframe>snippet to/FramePlayer?TrackEntryKey=…). Both are clipboard writes — there is no network call, so no share is recorded today. This is the share-event origin (§2.4).DeepDrftPublic.Client/Pages/FramePlayer.razor— the embeddable single-track player the embed snippet points at. Plays inside a third-party<iframe>should count (§1, edge cases).DeepDrftAPI/Controllers/StatsController.cs+DeepDrftModels/DTOs/HomeStatsDto.cs— the landed home-stats pattern the Plays card will consume.GET api/stats/homereturns a bare DTO; aggregation lives inTrackRepository.GetHomeStatsAsync/ITrackService; the controller is a thin boundary. This is the template the play/share read path mirrors (§4, §5).DeepDrftPublic.Client/Controls/NowPlayingStats.razor— the three-card hero stat row. The Plays card is the third card; it currently shows the static placeholder. This phase fills it.DeepDrftData(DeepDrftContext,TrackRepository,TrackManager,Migrations) — where new SQL tables/migrations and aggregation queries live.DeepDrftAPIowns the HTTP surface.DeepDrftPublicproxy controller (api/track/*) — the public site's browser→API proxy hop. A newapi/event/*orapi/stats/*write path needs a matching proxy route (the WASM client cannot reachDeepDrftAPIdirectly; SSR can).
1. The metrics, defined precisely
Three metrics, two core + one stretch:
1a. Play (core)
A play is a single listener's session of listening to one track. It is recorded once per track-listen, classified by how far the listener got:
-
partial — playback reached < 30% of the track's duration before the session ended (switched track, stopped, navigated away, closed tab).
-
complete — playback reached > 80% of duration.
-
middle band (30%–80%) — D1 RESOLVED (Daniel 2026-06-19): three-bucket
sampled. The middle band is its own bucket,sampled(a real listen that wasn't a skip and wasn't a finish), so the three buckets are exhaustive and non-overlapping:partial[0, 30%),sampled[30%, 80%],complete(80%, 100%]. The headline "Plays" number is the sum of all three (every started listen counts as a play); the buckets are the texture beneath it.Road not taken: folding the middle into "complete" (threshold "≥30% = a real play, else partial") — simpler, two buckets, but it discards the "listened to half" vs. "listened to the end" distinction, which is the most editorially interesting signal for a music collective ("which mixes do people actually finish?"). Three buckets chosen for that texture.
What starts a play candidate: a track's audio actually begins streaming for playback — i.e.
SelectTrackStreaming reaches the point where StartStreamingPlayback succeeds and IsPlaying
becomes true (the _streamingPlaybackStarted transition in StreamAudioWithEarlyPlayback). A track
that is staged (StageTrack) but never played does not count. A track that fails to load does
not count.
What classifies the bucket: the maximum playback position reached as a fraction of duration, captured when the listen ends (track switch, stop, organic end, or page unload). Not the position at the moment of ending — the high-water mark — so that seeking backward near the end doesn't demote a complete play to partial. (See seeks under edge cases.)
Why a high-water mark, not elapsed-listen-time: elapsed time would require accumulating play
duration across pauses and is more state to carry. The high-water position is already trivially
derivable from the progress callback (max(currentTime)/duration). For v1 the simpler model is the
right call; if "engaged listening time" becomes interesting later it's an additive metric, not a
reshape.
1b. Share (core)
A share is recorded when a listener performs a share action — not when they open the share
popover. Two actions exist today (SharePopover):
- Copy link (track or release) → records a share against that track or release, with a
channel = linktag. - Copy embed (track only) → records a share with
channel = embed.
A future "native share" (Web Share API) or per-platform button would add channels without reshaping the metric. The share count on the Plays-card payoff is total shares; the channel split is texture (and probably CMS-only, not public-facing, for v1).
De-dupe: copying the same link three times in a row is one intent, not three shares. Recommend a client-side debounce — at most one share event per (target, channel) per short window (e.g. 60s) per session. Cheap, prevents the obvious gaming, and matches how "copied!" already feels like one act.
1c. Unique listeners (stretch / "plus" — lower priority)
A unique listener is an approximate distinct-listener count, tied to a track or release. D3 RESOLVED (Daniel 2026-06-19, by default): all-time window — not rolling-30-day. All-time fits the "N listeners reached" framing and the Option-A mechanism (§3). This is the metric most in tension with the no-PII constraint; it is approximate by design (we estimate reach, we do not build identity). Mechanism: §3. Low-risk to revisit during its wave (§6) if implementation surfaces a reason — a rolling window is an additive aggregation, not a reshape. Built as part of the substrate (no longer an indefinite tail — see §6).
1d. Edge cases (apply to plays unless noted)
- Replays. Playing the same track twice in one session = two plays. The play event fires per
SelectTrackStreamingthat reaches playback. If a listener loops a track ten times, that's ten plays. Acceptable for v1 — looping is genuine listening. (Unique listeners absorbs the "but it's the same person" concern at the reach level.) - Seeks (within a play). Seeking does not start a new play. The high-water position keeps
climbing; seeking backward never lowers it. So "seek to the end to check the outro, then seek
back" still classifies as
complete— correct, they heard the end. - Seek-beyond-buffer re-request. The
GET api/track/{id}with aRangeheader duringSeekBeyondBufferis a byte re-fetch of the same play — it must not start a new play event. Because the play event is keyed off the player-serviceSelectTrackStreaminglifecycle (not the HTTP fetch), this is free:SeekBeyondBufferreuses_currentTrackIdand never callsSelectTrackStreaming. Instrument at the player-service level, never at the HTTP/media-client level — the media client fires multiple times per play. - Very short tracks. A 4-second sting: 30%/80% still apply proportionally. No special-casing for v1. (If "complete" on a 4s clip feels too cheap, a minimum-absolute-seconds floor is an additive tweak — flag as a tuning knob, not a v1 requirement.)
- Rapid skips. Listener clicks through ten tracks in five seconds. Each reaches
< 30%→ tenpartialplays. D2 RESOLVED (Daniel 2026-06-19): apply a minimum-engagement floor. A listen registers as a play only once playback reaches ≥ 3 seconds OR ≥ 5% of duration, whichever is smaller (so a sub-60s clip floors on the percentage; anything longer floors on the 3-second wall). Below the floor it is a preview/skip, dropped entirely (no event sent). This keeps the headline number honest — a skim through the archive isn't 40 plays — while still capturing genuine short partial listens. The floor is a single tunable constant (one place to change if the band later wants it looser/tighter). Road not taken: floor = 0 ("they hit play, it's a play") — simpler and defensible, but inflates the count on a browsing session. - Tab close / navigation mid-play. The play must still be recorded with its high-water bucket. This is the hardest delivery case and drives the beacon recommendation in §2.2.
- Embedded (
FramePlayer) plays. A play inside a third-party iframe is a real play and should count. The embed runs the same player stack, so it instruments for free — but it may run cross-origin, which bears on the unique-listener cookie/storage mechanism (§3) and the share attribution (an embed play could optionally carry the embedding page as a referrer dimension — out of scope for v1, flag as adjacent). - CMS / admin playback. Plays generated by an admin auditing tracks in the CMS should ideally not
pollute the public counts. The CMS is a separate app (
DeepDrftManager) and does not run this player stack, so this is mostly free — but a logged-in admin browsing the public site would count. Acceptable for v1 (low volume); flag as a known caveat.
2. Instrumentation
2.1 Where events originate
Play events: in StreamingAudioPlayerService, not the UI and not the HTTP layer. The player
service is the single chokepoint every playback path flows through (home StreamNow, gallery, queue
auto-advance, detail-page play, embed). Instrumenting once there covers all of them and dodges the
seek-beyond-buffer double-count trap.
Concretely, the player service grows a small internal play-session tracker:
- On the
_streamingPlaybackStartedtransition (playback actually begins): open a play session for_currentTrackId— record the trackEntryKey, the resolved release id (§2.3), the duration once the WAV header sets it, and start the high-water mark at 0. - On each progress tick (
OnProgressCallback, already firing ≤10/sec): advance the high-water mark (max(highWater, currentTime)). - On session-end — whichever comes first: organic end (
OnPlaybackEndCallback), a supersedingLoadTrackStreaming/ResetToIdle(track switch / stop / unload / dispose), or page unload (§2.2) — close the session: computehighWater/duration, apply the §1d floor, classify the bucket, and emit one play event. Then clear the tracker.
This keeps all play-counting logic in one place, behind one seam, testable against a fake interop. It does not change the playback path — it's an observer on transitions the service already makes.
Recommendation: factor the tracker into a small injectable collaborator (e.g. IPlayTracker /
PlayTracker) that the player service calls (OnPlaybackStarted, OnProgress, OnPlaybackEnded),
rather than inlining HTTP calls into the player. Keeps the player's single responsibility intact and
matches the repo's "logic in services, not in the playback path" discipline. The tracker owns the
beacon call and the de-dupe/floor logic. (Final structural call is staff-engineer's; this is the
steer.)
Share events: in SharePopover (or a small IShareTracker it calls). The CopyLink /
CopyEmbed handlers already exist and already know the target (track EntryKey or release
EntryKey + medium) and the channel. After a successful clipboard write, fire a share event. Apply
the §1b debounce in the tracker.
2.2 What gets sent, when, and how (fire-and-forget vs. tracked)
Recommendation: fire-and-forget sendBeacon for play and share events. These are telemetry, not
transactions — a dropped event is acceptable; blocking the UI or the navigation for one is not.
navigator.sendBeacon is purpose-built for exactly this (it survives page unload, which the
tab-close edge case requires) and is a tiny TS interop addition alongside the existing audio interop.
- Play event payload (sent once at session close):
{ trackEntryKey, releaseEntryKey?, medium?, bucket: "partial"|"sampled"|"complete", anonId? }. No duration or position is sent — the bucket is computed client-side, so the server stores a classification, not raw listening data (a privacy plus: we never transmit how long someone listened, only a coarse bucket).anonIdis the unique-listener token and is omitted entirely if the unique-listener feature is off or the listener has opted out (§3). - Share event payload:
{ targetType: "track"|"release", targetKey, channel: "link"|"embed", anonId? }. - Transport: a single new endpoint family
POST api/event/playandPOST api/event/share(proxied throughDeepDrftPublicfor the WASM client, same hop asapi/track/*).sendBeaconissues aPOSTwith a small JSON body; the endpoint returns202 Acceptedand does the write async. Unauthenticated (same posture as the public reads) — but rate-limited and validated (§2.5).
Why not a tracked/awaited call: the only thing we'd gain is a delivery confirmation we don't need and a failure path we don't want (a telemetry 500 must never surface to a listener). The beacon's "can't read the response" limitation is irrelevant — we don't act on the response.
Page-unload delivery: register a pagehide/visibilitychange→hidden handler that closes any open
play session via the beacon. This is the canonical pattern (analytics libraries all do this) and is
why sendBeacon over fetch matters — fetch is cancelled on unload, sendBeacon is not.
2.3 Resolving the release id for a play
The player today holds _currentTrackId (the track EntryKey) but not the release. A play event
wants both (the metric is "tied to individual tracks and releases"). Options:
- Carry release context on the
TrackDto/play call. When a play originates from a release detail page or the queue (PlayRelease), the caller knows the release. Thread a releaseEntryKey+ medium intoSelectTrackStreaming(optional param) or onto the staged context. Clean, explicit. - Resolve server-side from the track. The play event sends only
trackEntryKey; the API joins track→release at write or aggregation time. The track→release link already exists in SQL. Zero client plumbing; the release dimension is always correct even for plays that started without release context (e.g. StreamNow random track).
RESOLVED: option 2 (resolve server-side). The track→release join is authoritative and already in
DeepDrftData; sending only the track key keeps the client dumb and the payload minimal, and a
random-track play still gets correctly attributed to its release without the client knowing. The
client sends what it cheaply knows (track key); the server enriches. D4 RESOLVED (Daniel
2026-06-19): release plays are derived (sum of plays of the release's tracks), not a
separately-counted "release was played" event — exactly right for multi-track Cuts and trivially
correct for single-track Session/Mix. The client sends only the track key; release attribution and
release-total derivation are both server-side.
Shares already carry the right target directly (SharePopover knows track vs. release), so share
attribution needs no resolution step.
2.4 Avoiding double-counting
- Plays: one session = one event, enforced by the player-service tracker (only
SelectTrack Streamingopens a session; seek-beyond-buffer and progress ticks never do). Covered by §2.1. - Shares: the §1b per-(target,channel) debounce.
- Beacon retries:
sendBeacondoes not retry, so no transport-level duplication. If a future switch tofetchadds retries, an idempotency key on the event would be needed — not for v1.
2.5 Abuse / inflation resistance (privacy-light, so light-touch)
Anonymous unauthenticated write endpoints invite inflation. v1 posture: make casual gaming annoying, accept that determined gaming is possible (this is a band's vanity-and-texture counter, not ad-revenue telemetry — the "90s visitor counter vibe" Daniel wants). Measures:
- Server-side rate-limit per IP on the event endpoints (e.g. N events/minute) — coarse, stateless, standard ASP.NET rate-limiting middleware.
- The play engagement floor (§1d, D2) already drops trivial skim-spam.
- Reject malformed payloads (unknown bucket, missing track key, oversized body) at the controller.
- Out of scope for v1: bot detection, CAPTCHA, signed events, IP reputation. Flag as adjacent if the counts ever start mattering enough to defend.
3. Privacy-light anonymity mechanism (unique listeners)
D5 RESOLVED (Daniel 2026-06-19): Option A — client-minted random first-party localStorage id,
metric labelled "listeners," fingerprinting (Option C) rejected. Rationale and the road not taken
are preserved below — the three mechanism families and their trade-offs are kept as the record of
why A was chosen over B and C. The unique-listener metric needs some notion of "the same anonymous
listener seen again" without identifying who they are.
Option A — Anonymous client-minted id (random GUID in localStorage)
On first visit, the client mints a random GUID, stores it in localStorage (or a first-party cookie),
and sends it as anonId on play/share events. The server counts distinct anonIds per
track/release.
- Privacy: strong. The id is random, contains no PII, is first-party only, never leaves as anything but an opaque token, and the listener can clear it (clear site data) at will. It is effectively a "this browser, until you clear it" token — not a person.
- Accuracy: good-but-inflating. Same person on phone + laptop = 2 listeners. Cleared storage = new listener. Incognito = new listener each session. So it over-counts unique listeners (counts unique browser-installs-since-last-clear, which we relabel honestly as "listeners").
- Cross-origin embed caveat: an embedded
FramePlayeron a third-party site cannot read the first-partylocalStorageofdeepdrft.com(storage is partitioned). So embed plays would mint a separate id per embedding site. Acceptable — embed plays are a minority and over-counting is the known direction of error. - Consent: a random first-party id with no cross-site tracking is the lightest-touch case under GDPR/ePrivacy — widely treated as not requiring a consent banner when used purely for first-party aggregate counts (this is the "privacy-light" sweet spot). Recommend a short privacy-note line rather than a cookie wall.
Option B — Salted/rotating daily identifier (no stored id at all)
The server (or client) derives a per-day token by hashing IP + User-Agent + a daily-rotating salt.
No id is stored on the client. Distinct tokens per day ≈ distinct visitors per day; the salt rotation
means yesterday's tokens can't be correlated to today's (no long-term tracking).
- Privacy: very strong on the no-persistent-identifier axis (nothing stored client-side, no cross-day linkage). This is the pattern Plausible/Fathom-style privacy analytics use.
- Accuracy: coarser. IP+UA collides (everyone behind one NAT/office/carrier-CGNAT shares a token → under-counts) and rotates daily (can only ever produce "unique per day," never "unique all-time" — by design). Mobile IPs shift constantly → over-counts. Net: noisy, and only meaningful as a daily figure, which fights the "all-time plays counter" vibe.
- Architecture cost: the IP is only reliably available server-side (the client doesn't know
its own public IP, and
X-Forwarded-Foris already handled server-side perDeepDrftAPIconfig). So the token must be computed at the API, not the client — meaning the unique-listener dimension is derived entirely server-side from request metadata, and the client sends noanonIdat all. That is actually a privacy plus (the client transmits nothing identifying) but ties unique-listeners to a daily-rolling model.
Option C — Coarse browser fingerprint
Derive an id from canvas/font/hardware fingerprinting signals.
- Privacy: worst. Fingerprinting is precisely what privacy regulation and browser vendors are actively killing; it tracks across sites and survives storage-clearing. Directly contradicts "privacy-light." Rejected outright — listed only for completeness.
Resolution (D5 — Option A)
Option A (client-minted random first-party id) is the chosen mechanism, with the metric honestly labelled "listeners." Reasons:
- It fits the product: a band wants an all-time "N listeners reached" figure, which A supports and B (daily-only) structurally cannot.
- It is genuinely privacy-light: opaque random token, first-party, clearable, no cross-site correlation, no fingerprinting — the lightest mechanism that still answers "all-time unique."
- It keeps the server simple (count distinct tokens) and the client honest (one tiny localStorage read).
- We label the metric "listeners," not "people," and accept the known over-count. For a vanity texture stat this is the right honesty/effort trade.
Road not taken: Option B (server-derived salted daily token — stores nothing client-side) was the fallback for a stronger "stores nothing" posture, at the cost of the metric becoming daily-unique and noisier; rejected because the product wants an all-time reach figure, which B structurally cannot give. Option C (fingerprint) rejected outright — it is exactly what privacy regulation and browser vendors are killing, and contradicts "privacy-light." A and B are not mutually exclusive long-term (A for all-time reach, B-style for daily actives), but v1 commits to A.
Sequencing note (changed 2026-06-19): unique-listeners is no longer an indefinite stretch
tail. Under the bottom-up re-sequencing, Daniel wants everything finished before the card lights
up — so the anonId layer is folded into the substrate build (§6, wave 16.3) as the lowest-priority
/ last of the metric layers, not an optional dangler. The play/share counters still need no anonId
and land first; unique-listeners stacks on top before the capstone card.
4. Storage & aggregation model
4.1 Event capture: log vs. counter vs. both
Three shapes:
- Rolled-up counters only. One row per (track, bucket) with an integer count, incremented on each event. Tiny storage, trivial read, no history, no unique-listener support (can't count distinct without storing the distinct tokens). Pure "90s hit counter."
- Append-only event log only. One row per event. Full fidelity, supports any future metric (unique listeners, time-of-day, channel splits, retention), but every read is an aggregation query over a growing table and write volume = play volume.
- Both — event log + periodic/online rollup. Events append to a log; counters are maintained (either incrementally on write, or by a periodic aggregation pass) for the hot read path.
Recommendation: a lean version of (3) — an append-only event log as the source of truth, plus a small rolled-up counter table for the home-card hot read. Reasoning:
- The home Plays card is read on every public-site landing; it must be a single cheap indexed read,
not a
COUNT(*)over an event table. That argues for a counter. - But unique-listeners requires retaining distinct tokens, and "which mixes get finished" texture wants the bucket/per-target fidelity — both argue for a log.
- (3) gets both without forcing a premature choice, and matches the existing
HomeStatsDtopattern (the card reads a pre-aggregated DTO; it never queries raw).
D6 RESOLVED (Daniel 2026-06-19, by default): incremental-on-write rollup. The event-write transaction also bumps the counter row — no background job to stand up. Road not taken: periodic batch aggregation, the escape hatch if write volume ever makes the incremental bump contended; for a collective-scale site incremental is fine and simplest. Low-risk to revisit during its wave (§6) if implementation surfaces a reason — switching to a periodic pass later doesn't change the schema, only how the counter is fed.
4.2 Conceptual SQL shape (not full schema — that's staff-engineer's)
In DeepDrftData, new tables roughly:
play_event(the log):id,track_entry_key(ortrack_idFK),release_id(resolved at write, §2.3),bucket(enum: partial/sampled/complete),anon_id(nullable — present only when unique-listeners is on and the listener didn't opt out),created_at. Indexed on(track_id),(release_id), and(anon_id)for the distinct-count query.share_event(the log):id,target_type(track/release),target_id,channel(link/embed),anon_id?,created_at.play_counter(the rollup, optional in v1 if reads stay cheap): per (track_id) and per (release_id), columns forpartial_count,sampled_count,complete_count,share_count, and atotal_playsderived/stored. This is what the home-stats aggregation reads.
anon_id is the only field that touches anonymity, it is nullable, and it is never joined to any
identity table because none exists. Dropping the unique-listener feature later = stop writing the
column; the play/share counts are unaffected.
Note on the dual-database split: this is all SQL (it's metadata/counters, not binary content).
The FileDatabase vault is not involved. Aggregation lives in TrackRepository alongside
GetHomeStatsAsync; the API surface lives in DeepDrftAPI.
4.3 New API surface (sketch)
Writes (unauthenticated, rate-limited, proxied through DeepDrftPublic):
POST api/event/play— body{ trackEntryKey, bucket, anonId? }; returns202. Resolves release server-side, appends toplay_event, bumpsplay_counter.POST api/event/share— body{ targetType, targetKey, channel, anonId? }; returns202.
Reads (unauthenticated, mirror GET api/stats/home):
- Extend
GET api/stats/hometo include the site-wide play total (and optionally share total) for the home card — the minimal payoff (§5). The card already does one round-trip to this endpoint; adding fields is the smallest possible change. GET api/stats/track/{entryKey}andGET api/stats/release/{entryKey}— per-target play/share/ listener figures, for the (future) detail-page display. Not required for the home-card payoff; build when a detail-page surface wants them.
CMS-facing reads (the channel splits, bucket breakdowns, per-track leaderboards) are a separate,
later, DeepDrftManager-side concern — explicitly out of v1 scope, flagged adjacent.
5. The Plays card payoff
Today NowPlayingStats.razor's third card renders XXX / Plays (Coming Soon) — a static odometer
placeholder. Once this phase lands, the card shows a real site-wide play total in the same
odometer treatment (the "90s visitor counter" vibe is already the intended aesthetic — the
placeholder is literally styled as an odometer). The minimal, correct payoff:
- Primary figure: total plays site-wide (sum across all tracks), rendered in the odometer.
- Secondary line: D7 RESOLVED (Daniel 2026-06-19, by default). The card gets a secondary line (the other two cards both have primary + secondary). Because the bottom-up re-sequencing now lands unique listeners before the card (§6), the secondary line can be unique listeners ("N listeners") from day one — the metric the card was always reaching for. Completion-rate ("N% finished") and total-shares ("N shared") remain available alternatives if listeners reads oddly in the odometer treatment; final pick is a small render-time call during the capstone wave, low-risk to revisit. (Under the prior early-card sequencing this had to be completion-rate or shares because listeners shipped later; the re-sequencing removes that constraint.)
Mechanically: add TotalPlays (and the chosen secondary) to HomeStatsDto, populate it in
TrackRepository.GetHomeStatsAsync from play_counter, and the card reads it through the existing
IStatsDataService.GetHomeStats() path — the same persistent-state-bridged single round-trip the
other two cards already use. No new client data path; the card just stops being static.
This is deliberately the first visible payoff and the smallest — the whole counter substrate earns its keep the moment the home number goes live, before any per-track surface or unique-listener work.
6. Phasing — bottom-up (Daniel directive, 2026-06-19)
Re-sequenced bottom-up: foundation first, metrics stacked on the substrate, the user-visible Plays-card flip is the capstone built LAST. This reverses the earlier "visible win comes early" framing. Daniel: "do the phasing from the bottom up, that seems more stable. I won't care about the live card until everything is finished." So the entire telemetry substrate and all metrics (including unique listeners, which is no longer an indefinite tail) land before the card lights up.
Waves run in strict sequence — each builds on the layer beneath it.
- 16.1 — Foundation: capture seam + transport + event log (no card consumption). The substrate,
end to end, with nothing reading it yet:
- Player-service play-session tracker (§2.1): opens on playback-start, advances the high-water mark on the existing progress callback, closes on track-switch / stop / organic-end / page-unload. Applies the §1d/D2 engagement floor (≥3s or ≥5%, whichever smaller).
- Share tracker in
SharePopover(§1b) with the per-(target,channel) debounce. sendBeaconinterop +pagehide/visibilitychangeunload handler (§2.2).POST api/event/{play,share}endpoints, proxied throughDeepDrftPublic(§2.2), with IP rate-limiting + payload validation (§2.5).- Append-only
play_event/share_eventSQL log + incrementalplay_counterrollup (§4, D6) inDeepDrftData. - Server-side release resolution + derived release totals (§2.3, D4) — client sends only the track key.
- At this point events flow and counters accumulate, but no
anonIdis written and nothing reads the counters. This is the stable base everything stacks on. Cold-start wave — nothing gates it.
- 16.2 — Completion-bucket classification + shares. The metric texture on top of the raw capture:
the three-bucket classification (§1a/D1 —
partial/sampled/complete, headline = sum) wired through the tracker → event payload → log → counter columns, and the share-channel split (link/embed) landing inshare_event. (Much of the bucket plumbing is natural to build alongside 16.1; 16.2 is the boundary where the classification is correct and exhaustive end to end, including counter columns per bucket.) Depends on 16.1. - 16.3 — Unique-listener
anonIdlayer (D5, lowest-priority metric). The anonymity mechanism (§3, Option A): mint/read the client first-partylocalStorageid, threadanonIdonto the event payloads, store it nullable on the event log, count distinct server-side (all-time, D3). The last of the metric layers — folded into "everything finished" per the directive, but explicitly the lowest-priority and last-built of the substrate. Depends on 16.1 (event payload + storage); builds on 16.2. - 16.4 — Per-target / CMS stats surfaces
[speculative].GET api/stats/{track,release}/{key}per-target reads; CMS analytics views (bucket splits, channel splits, leaderboards). Speculative, not committed scope — the event log already supports it. Position: before the capstone if a per-target surface is wanted (e.g. to validate the metrics visually in the CMS before the public card goes live), otherwise skippable. Does not gate the card. Depends on 16.1–16.3 for the data it would read. - 16.5 — Home Plays-card payoff (CAPSTONE, built LAST). Extend
HomeStatsDto+GetHomeStatsAsyncwithTotalPlays(+ the secondary line — unique listeners now available, D7); flipNowPlayingStats's third card from theXXX / Plays (Coming Soon)placeholder to live, through the existing persistent-state-bridgedGET api/stats/homeround-trip (§5). The final wave — built only once the full substrate and all metrics (16.1–16.3) are in place. Per Daniel, the live card is explicitly the last thing built; there is no early-payoff intermediate.
Hard dependencies (strict bottom-up chain):
16.1 → 16.2 → 16.3 → (16.4 optional) → 16.5.
16.1 is the only cold-start wave. 16.5 (the card) sits at the top and depends on the whole stack
beneath it being finished. 16.4 is speculative and off the critical path to the card.
Verify-before-build (gates 16.1): confirm the DeepDrftPublic proxy can host a POST api/event/*
write route with the same proxy idiom as api/track/* (the WASM client cannot reach DeepDrftAPI
directly). This is the one infrastructural assumption the spec rests on; cheap to confirm, blocking if
wrong.
10. Product decisions — RESOLVED (Daniel 2026-06-19)
All seven decisions are settled. D1, D2, D4, D5 were resolved on Daniel's explicit pick of the recommendation; D3, D6, D7 are resolved-by-default (recommendation adopted) and remain low-risk to revisit during their wave if implementation surfaces a reason. The "road not taken" for each is preserved in the body section so future implementers know what was rejected and why.
- D1 — Middle-band (30–80%) treatment. → RESOLVED: three-bucket
sampled. Exhaustive partial/sampled/complete; headline plays = sum of all three. Rejected: binary partial/complete fold. (§1a) - D2 — Engagement floor before a play counts. → RESOLVED: floor on. A listen counts only at ≥3s OR ≥5% of duration, whichever is smaller; below that it's a dropped preview/skip. Rejected: floor = 0. Single tunable constant. (§1d)
- D3 — Unique-listener window. → RESOLVED (by default): all-time. Fits "N listeners reached" and the Option-A mechanism. Rejected: rolling-30-day. Low-risk to revisit in wave 16.3. (§1c)
- D4 — Release plays: derived or separately counted. → RESOLVED: derived (release plays = sum of its tracks' plays), server-side. Correct for multi-track Cuts and single-track Session/Mix alike. Rejected: separate "release was played" event. (§2.3)
- D5 — Unique-listener anonymity mechanism. → RESOLVED: Option A — client-minted random
first-party
localStorageid, metric honestly labelled "listeners," all-time. Rejected: Option B (server-derived salted daily token — stores nothing but only daily-unique); Option C (fingerprint — contradicts privacy-light) rejected outright. Headline decision. (§3) - D6 — Rollup strategy. → RESOLVED (by default): incremental-on-write counter, no background job. Rejected: periodic batch aggregation (kept as the escape hatch if write volume contends). Low-risk to revisit in wave 16.1. (§4.1)
- D7 — Home Plays-card secondary line. → RESOLVED (by default): unique listeners ("N listeners"). The bottom-up re-sequence lands listeners (16.3) before the card (16.5), so the card can show the metric it was always reaching for. Alternatives kept available: completion-rate or total-shares, a small render-time call during 16.5. Low-risk to revisit in the capstone wave. (§5)
Working with this spec
- Mirrors the established product-notes convention (cross-refs up top, numbered sections, decisions
called out, phasing with explicit dependencies) — see
phase-15-visualizer-controls-enhancements.md/phase-11-public-site-enhancements.md. - Decisions D1–D7 are resolved (Daniel 2026-06-19) and folded inline; status is design-complete. Waves are re-sequenced bottom-up (16.1 → 16.5, card last) per Daniel's directive of the same date.
- When waves land, doc-keeper moves them to
COMPLETED.md; the per-wave bodies are written to travel cleanly.