diff --git a/PLAN.md b/PLAN.md index bd28d87..47a718f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -239,6 +239,27 @@ Sequenced as **eight waves**; the critical path is `11.A → 11.B → 11.C → 1 --- +## Phase 16 — Anonymous Play & Share Tracking + +The phase deferred behind the home-hero **Plays** stat card (`NowPlayingStats.razor`'s third card, today a static `XXX / Plays (Coming Soon)` odometer placeholder). Adds a **privacy-light, anonymous** telemetry layer to the public site: counting **plays** (bucketed by completion) and **shares**, tied to individual **tracks and releases**, plus an optional **unique-listener** "plus" metric. Hard constraint: **no accounts, no PII, anonymous identification only** — the unique-listener metric in particular is solved within that constraint, not around it. Full design, metric definitions, instrumentation seam, anonymity-mechanism options, storage model, the card payoff, and wave decomposition: `product-notes/phase-16-play-share-tracking.md`. + +**Architectural spine.** Plays are instrumented at **one seam** — the `StreamingAudioPlayerService` playback lifecycle (not the UI, not the HTTP/media-client layer, which fires multiple times per play via seek-beyond-buffer). A small play-session tracker opens on playback-start, advances a high-water position on the existing progress callback, and closes (classifying the §1 completion bucket) on track-switch / stop / organic-end / page-unload. Shares instrument at the **real** share surface (`SharePopover`'s Copy-link / Copy-embed actions — clipboard writes that record nothing today). Events ship **fire-and-forget via `sendBeacon`** to new `POST api/event/{play,share}` endpoints (proxied through `DeepDrftPublic`, same hop as `api/track/*`), land in an **append-only SQL event log + incremental counter rollup** in `DeepDrftData`, and the home card reads the total through the **existing** `GET api/stats/home` / `HomeStatsDto` / `IStatsDataService` path (the same single persistent-state-bridged round-trip the other two cards use). Release attribution is **resolved server-side** from the track→release join (client sends only the track key); release plays are **derived** (sum of their tracks' plays). All SQL — the FileDatabase vault is not involved. + +**Completion buckets (agreed thresholds).** `partial` < 30%, `complete` > 80%; the 30–80% middle band is proposed as its own `sampled` bucket so the three are exhaustive and non-overlapping (headline plays = sum of all three) — see spec §1a / decision **D1**. + +**Sequenced as four waves.** `16.A → 16.B`, `16.A → 16.C`, `16.A → 16.D`. 16.A is the only cold-start wave. + +- **16.A — Play & share counters (core).** Player-service play tracker (three-bucket classification + engagement floor), share tracker in `SharePopover`, `sendBeacon` interop + `POST api/event/{play,share}` (rate-limited), `play_event`/`share_event` log + incremental `play_counter` rollup, server-side release resolution. **No `anonId` yet.** Free-floating cold-start wave. +- **16.B — Home Plays-card payoff.** Extend `HomeStatsDto` + `GetHomeStatsAsync` with `TotalPlays` (+ a secondary line, D7); flip the third card from placeholder to live. **Depends on 16.A.** The visible win — sequence immediately after 16.A. +- **16.C — Unique listeners (stretch / "plus").** The anonymity mechanism (recommend a client-minted random first-party `localStorage` id; alt: server-derived salted daily token; fingerprinting rejected — see §3 / **D5**): thread an `anonId` onto event payloads, count distinct server-side, expose per-target and/or as the card's secondary line. **Depends on 16.A. Explicitly lower priority** — defers indefinitely without stranding 16.A/16.B. +- **16.D — Per-target stats surfaces.** `[speculative]` — detail-page play/share/listener display + CMS analytics views (bucket/channel splits, leaderboards). Not in the agreed scope; the event log already supports it. Build when a surface wants it. + +**Open product decisions (D1–D7, spec §10) — unresolved, awaiting Daniel.** Headline is **D5** (unique-listener anonymity mechanism). Others: D1 (middle-band bucket), D2 (engagement floor before a play counts), D3 (unique-listener window), D4 (release plays derived vs. counted), D6 (rollup strategy), D7 (card secondary line). Resolve before 16.A is decomposed; recommendations are carried in the spec. + +**Adjacency to deferred Identity / accounts (the un-phased backlog item above).** This phase is the deliberate **anonymous** answer to "how many plays" — it does **not** need the accounts/identity work and must not be entangled with it. If identity ever lands, per-user listening history is an additive layer above this anonymous substrate, not a replacement. + +--- + ## Working with this file - **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 1–5. Phase numbers are organisational, not sequencing. diff --git a/product-notes/phase-16-play-share-tracking.md b/product-notes/phase-16-play-share-tracking.md new file mode 100644 index 0000000..39de94d --- /dev/null +++ b/product-notes/phase-16-play-share-tracking.md @@ -0,0 +1,513 @@ +# Phase 16 — Anonymous Play & Share Tracking (Design Spec) + +Status: **design-draft, open for Daniel review** (decision points in §10 are unresolved). Author: +product-designer. Date: 2026-06-18. **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`. + +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, and `ResetToIdle` (stop/unload/switch). `_currentTrackId` holds the + current `EntryKey`. **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; `OnPlaybackEndCallback` is the organic end-of-stream + seam (and the only place `TrackEnded` fires). `Duration` is set from the WAV header during load. +- `DeepDrftPublic.Client/Services/AudioInteropService.cs` — `SetOnProgressCallbackAsync` / + `SetOnEndCallbackAsync` are the JS→.NET callbacks already wired in `InitializeAsync`. Progress is + throttled to ~10/sec on the JS side already. +- `DeepDrftPublic.Client/Services/QueueService.cs` — auto-advance orchestrator. Album playthroughs + flow `PlayRelease → PlayCurrent → SelectTrackStreaming` per track; `OnTrackEnded` advances. Every + track in an album play is an independent `SelectTrackStreaming` call, 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 `