Compare commits
39 Commits
62007a6517
...
fbd298b9c3
| Author | SHA1 | Date | |
|---|---|---|---|
| fbd298b9c3 | |||
| 3da6591194 | |||
| da60296cf8 | |||
| 4320ea8029 | |||
| 678d3f66ad | |||
| be04e53a97 | |||
| 58b30d3c13 | |||
| be1a55fd37 | |||
| 9d0ce99a5d | |||
| 1d387c2a34 | |||
| fe3819f378 | |||
| cfcc2693f2 | |||
| 621c4f9cb3 | |||
| 67eeb38529 | |||
| 9aa66e8a62 | |||
| 3b9ca700c9 | |||
| 4317a2f9e7 | |||
| 297805b5a8 | |||
| 944f23a88c | |||
| 75e5d99aea | |||
| c084efa78e | |||
| f296bbdf00 | |||
| ebbaa3f84f | |||
| a715f4b28d | |||
| 90555dc4e0 | |||
| 0fbf81b23e | |||
| 4114aa0be4 | |||
| 884ccab826 | |||
| 3c1998de4f | |||
| 622ee940f4 | |||
| 18e171213c | |||
| e9c61bac1a | |||
| dbd90ee52a | |||
| 1b7861e168 | |||
| 098020db32 | |||
| 912256d99a | |||
| 1931574ad4 | |||
| 25aba1cbb7 | |||
| 81d0028f2b |
@@ -9,3 +9,4 @@
|
||||
*.conf text eol=lf
|
||||
# Vendor JS pinned LF — avoids CRLF churn on Windows checkout
|
||||
DeepDrftShared.Client/wwwroot/js/parallax/parallax.js text eol=lf
|
||||
DeepDrftShared.Client/wwwroot/js/knob/knob.js text eol=lf
|
||||
|
||||
@@ -125,7 +125,7 @@ dotnet ef database update --project DeepDrftData --startup-project DeepDrftAPI
|
||||
All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitignored `environment/` files:
|
||||
|
||||
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
|
||||
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 600 — budget for server-side persist after the body is fully sent).
|
||||
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 1200 — budget for server-side persist after the body is fully sent).
|
||||
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
|
||||
|
||||
## Folder-Level Guidance
|
||||
|
||||
+131
@@ -6,6 +6,137 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Player-Bar Queue View: Wave 17.4 — Add-to-Queue affordance (landed 2026-06-19)
|
||||
|
||||
**Landed:** 2026-06-19 on dev.
|
||||
|
||||
- **What:** The append-only Add-to-Queue affordance on detail pages — a new shared `AddToQueueButton.razor` control wired at every detail-page play site, enabling listeners to add a release or individual track to the queue without interrupting the current track. `ReleaseGallery` browse-grid cards are intentionally excluded (OQ10, deferred).
|
||||
|
||||
- **Why:** Phase 11 wave 11.F built the `Enqueue`/`EnqueueRange` append path in the queue engine but gave it no UI entry point. Wave 17.4 lights that dormant path, completing the Add-to-Queue capability Daniel stated as commitment 4 of Phase 17. It was split from 17.2 because it depends only on the existing engine append members (not on 17.1's new `Move`/`RemoveAt`), allowing it to land in parallel.
|
||||
|
||||
- **Shape:**
|
||||
- **`AddToQueueButton.razor`** (`DeepDrftPublic.Client/Controls/AddToQueueButton.razor`): shared append-only button with two modes: track mode (`Enqueue` — called with a single `TrackDto`) and release mode (`EnqueueRange` — called with an ordered `IReadOnlyList<TrackDto>`). Material `PlaylistAdd` glyph; tooltip reads "Add to queue" (track mode) or "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive / when the cascade is absent; append-only — does not play, does not navigate.
|
||||
- **`CutDetail.razor`** (header): release-mode `AddToQueueButton` beside the header play affordance, passing the `TrackNumber`-ordered track list.
|
||||
- **`CutDetail.razor`** (track rows): track-mode `AddToQueueButton` beside the per-row play affordance.
|
||||
- **`SessionDetail.razor`** (hero play): track-mode `AddToQueueButton` beside the Session hero play button.
|
||||
- **`MixDetail.razor`** (hero play): track-mode `AddToQueueButton` beside the Mix hero play button.
|
||||
- **Excluded sites:** `StreamNowButton` (no fixed track to resolve — OQ9) and `ReleaseGallery` cards (no play button today — OQ10, deferred to `TODO.md`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Player-Bar Queue View: Wave 17.2 — Docked queue overlay (landed 2026-06-19)
|
||||
|
||||
**Landed:** 2026-06-19 on dev.
|
||||
|
||||
- **What:** The editable docked-player queue overlay — a Queue toggle button in the non-Fixed (docked) player bar and a new `QueueOverlay.razor` modal that hosts the shared `QueueList` in editable mode. Listeners can now see, reorder, remove from, and jump within the queue while a release is playing. Also fixed a pre-existing `QueueChanged` unsubscribe leak in `AudioPlayerBar.DisposeAsync`, hardened `PlayRelease` with a defensive copy, and styled the global `deepdrft-queue-*` CSS classes for the first time (first styling for the `QueueList` classes that 17.1 shipped unstyled).
|
||||
|
||||
- **Why:** Phase 11 built the queue engine and Phase 17 wave 17.1 built the shared `QueueList` component, but neither surfaced the queue visually in the docked player. Wave 17.2 delivers Daniel's commitment 2 — a visible, editable queue panel in the non-Fixed player bar.
|
||||
|
||||
- **Shape:**
|
||||
- **Queue toggle button** (`AudioPlayerBar` / `PlayerTransportZone`): shown only when `!Fixed && Items.Count > 0`, placed below the transport-button row and left of the timestamp. Material `QueueMusic` glyph; renders in an active/highlighted state when the overlay is open.
|
||||
- **`QueueOverlay.razor`** (`DeepDrftPublic.Client/Controls/QueueOverlay.razor`): screen-centered tinted modal borrowing the `WaveformVisualizerControlPopover` `MudOverlay` idiom (`DarkBackground="true"`, `Modal="true"`). Panel stops click propagation; scrim-click closes the overlay (drag-safe: capture div sits above the scrim during a drag so releasing outside the panel never fires the close handler). Auto-closes if a removal empties the queue. Hosts `QueueList` in `Editable="true"` mode.
|
||||
- **`AudioPlayerBar` wiring**: reorder → `Move(fromIndex, toIndex)`; remove → `RemoveAt(index)` (auto-closes overlay when queue empties); row-jump → `PlayRelease(Items, index)`; Clear header action → new `ClearUpcoming()`. Fixed pre-existing `QueueChanged` unsubscribe leak in `DisposeAsync`.
|
||||
- **`QueueList.razor` — current-row remove suppression**: the remove (×) control is now hidden on the currently-playing row (`Editable && !isCurrent`), enforcing OQ3's "the current track cannot be removed" rule in the UI. Reorder of the current row is still permitted.
|
||||
- **Engine — `ClearUpcoming()`** (`IQueueService` / `QueueService`): new additive member. Removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0`; re-emits `QueueChanged`; touches no playback. Satisfies OQ5's requirement that Clear does not stop or remove the current track.
|
||||
- **Engine — `PlayRelease` defensive copy**: `PlayRelease` now always materializes a defensive copy of its input list (`tracks.ToList()`) so it can never alias the caller's `Items` list — fixes a row-jump bug where jumping via `PlayRelease(Items, index)` could mutate the live `Items` reference mid-operation.
|
||||
- **CSS — `deepdrft-queue-*` classes** (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`): overlay/list chrome classes added to the global stylesheet (portaled overlay content cannot use scoped CSS). This is also the first styling pass for the `QueueList` classes 17.1 introduced without accompanying styles.
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Player-Bar Queue View: Wave 17.1 — Engine additions + shared QueueList (landed 2026-06-19)
|
||||
|
||||
**Landed:** 2026-06-19 on dev.
|
||||
|
||||
- **What:** The queue-engine additions and the shared presentational list component that waves 17.2 and 17.3 will consume. No player-bar wiring, overlay, embed, or Add-to-Queue affordance landed — those remain in waves 17.2 and 17.3.
|
||||
|
||||
- **Why:** Wave 17.1 is the cold-start prerequisite for the full Phase 17 queue-view surface. The engine additions are interop-free state mutations that land without any UI decision being made; `QueueList` is the single presentational "view" both the docked overlay and the embedded panel will share (one source, multiple views), so it is cleanest to build and test it before the hosting contexts exist.
|
||||
|
||||
- **Shape:**
|
||||
- **Engine — `Move(int fromIndex, int toIndex)`** (`IQueueService` / `QueueService`): reorders `Items` in-place, adjusts `CurrentIndex` so the same track stays current across the move, re-emits `QueueChanged`. Never re-streams or interrupts the playing track. No-op (no `QueueChanged`) when either index is out of range or the indices are equal. Interop-free; safe during prerender.
|
||||
- **Engine — `RemoveAt(int index)`** (`IQueueService` / `QueueService`): removes the item at `index`, adjusts `CurrentIndex` (a track before current decrements the index; a track after current leaves it unchanged; removing the current track does not stop playback — the track runs to natural end while `CurrentIndex` resolves to the new slot occupant; removing the last remaining item leaves the queue empty and dormant with `CurrentIndex == -1`). Re-emits `QueueChanged`. No-op when `index` is out of range. Interop-free; safe during prerender.
|
||||
- **Engine — dormant-`Enqueue` coherence (OQ8):** `Enqueue` and `EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) now set `CurrentIndex` to 0 so a subsequent play/skip is correct. Does **not** auto-play — add is not play. `PlayCurrent` is never called from these paths; the methods remain interop-free.
|
||||
- **`QueueList.razor`** (`DeepDrftPublic.Client/Controls/QueueList.razor`): purely presentational component. Renders `Items` as an ordered list with the current track marked (position number + `GraphicEq` now-playing icon on the current row). `Editable` flag gates drag-reorder handles and per-row remove controls: when `true`, wraps rows in a `MudDropContainer`/`MudDropZone` for reorder; when `false`, renders a plain `<div>` (read-only; the embed's fixed-order shared queue). Reorder, remove, and row-jump are surfaced to the parent as `EventCallback<(int FromIndex, int ToIndex)> OnReorder`, `EventCallback<int> OnRemove`, and `EventCallback<int> OnJump` respectively — the component calls no `IQueueService` method itself. Owns no data fetch or player wiring. Runs during prerender without JS interop (drag work is client-only and inert when no drag occurs).
|
||||
- **`QueueServiceTests`**: T1–T10 added, covering `Move` (in-range, out-of-range, same-index no-ops; current-track identity preserved across reorders) and `RemoveAt` (before/after/at current; last-item dormant; out-of-range no-op; playback not stopped).
|
||||
|
||||
---
|
||||
|
||||
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.5 — Home Plays-card capstone (landed 2026-06-19)
|
||||
|
||||
**Landed:** 2026-06-19 on dev.
|
||||
|
||||
- **What:** The capstone wave — the live home hero Plays card and the `HomeStatsDto` extension that powers it. `NowPlayingStats.razor`'s third card, previously a static "XXX / Plays (Coming Soon)" odometer placeholder, now renders the live `TotalPlays` figure in the existing odometer treatment with a secondary "N listeners" line (`UniqueListeners`). No new fetch path, no new client service, no migration — the card consumes the same `HomeStatsDto` round-trip the other two cards already use. Privacy footer line (`DeepDrftFooter.razor`, `.deepdrft-footer-privacy`) also landed as part of the same merge: a quiet fine-print disclosure of the anonymous `anonId` token, using the Variant 1 approved copy from `product-notes/phase-16-privacy-note.md`.
|
||||
|
||||
- **Why:** The Plays card was deliberately held as the final wave (sequenced bottom-up per Daniel's directive) so the substrate (16.1 capture + rollup, 16.2 bucket/channel, 16.3 anonId + distinct-listener aggregation) would be solid before any read surface appeared. Wave 16.4 (per-target / CMS stats views) was speculative and skipped; the event log supports it later if wanted. With 16.5 landing, Phase 16 is complete.
|
||||
|
||||
- **Shape:**
|
||||
- **`HomeStatsDto` extended** (`DeepDrftModels/DTOs/HomeStatsDto.cs`): two new fields — `TotalPlays` (`long`; site-wide sum of every `play_counter` row's `PartialCount + SampledCount + CompleteCount`, all-time; zero until the telemetry migration is applied — expected, not an error) and `UniqueListeners` (`int`; distinct non-null `anon_id` across all play events, all-time; over-counts by design, honestly labelled "listeners"). No other DTO changes.
|
||||
- **`StatsController` composition** (`DeepDrftAPI/Controllers/StatsController.cs`): now injects `ITrackService` (existing) **and** `IEventService` (Phase 16 event domain). `GetHome` assembles `HomeStatsDto` in two sequential best-effort reads: track-domain aggregation via `ITrackService.GetHomeStats` (existing; failure returns 500 as before); play/listener figures via `IEventService.GetTotalPlayCount` and `IEventService.GetDistinctListenerCount` (Phase 16; a telemetry failure or not-yet-applied migration leaves them at 0 rather than 500-ing the whole endpoint). Neither domain reaches into the other's tables; the controller is the composition seam only.
|
||||
- **`IEventService` additions** (`DeepDrftData/IEventService.cs`): `GetTotalPlayCount(ct)` → `ResultContainer<long>` and `GetDistinctListenerCount(ct)` → `ResultContainer<int>` (wave 16.3 added the distinct-listener overloads; `GetTotalPlayCount` is the one new member for 16.5).
|
||||
- **`EventRepository.CountTotalPlaysAsync`** (`DeepDrftData/Repositories/EventRepository.cs`): sums `PartialCount + SampledCount + CompleteCount` directly over `PlayCounters` via LINQ — **not** `PlayCounter.TotalPlays` (which is an EF-ignored computed property and not translatable). An empty counter table sums to 0.
|
||||
- **`NowPlayingStats.razor`** (`DeepDrftPublic.Client/Controls/NowPlayingStats.razor`): third card now renders `@_stats.TotalPlays` in `.hero-stat-odometer` and `@_stats.UniqueListeners listeners` as `.hero-stat-sub`. No change to the `PersistentComponentState` bridge or `IStatsDataService` fetch path — the DTO fields arrive in the same existing round-trip.
|
||||
- **`DeepDrftFooter.razor`** (`DeepDrftPublic.Client/Layout/DeepDrftFooter.razor`): privacy disclosure paragraph (`.deepdrft-footer-privacy`) added: "We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag's gone."
|
||||
- **No migration.** The `play_counter` rollup table was created by `20260619155610_AddPlayShareTelemetry` (wave 16.1; authored, not yet applied — Daniel-gated). The `CountTotalPlaysAsync` query returns 0 gracefully until that migration runs.
|
||||
|
||||
---
|
||||
|
||||
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.1 — Foundation (landed 2026-06-19)
|
||||
|
||||
**Landed:** 2026-06-19 on dev.
|
||||
|
||||
- **What:** The anonymous telemetry **substrate** — foundation end-to-end with nothing reading it yet. No `anonId` written; no home-card/read surface changed (those are waves 16.3 and 16.5). The full capture-and-storage pipeline is in place: client-side play-session tracker and share tracker, `sendBeacon` transport with page-unload handler, proxied and rate-limited intake endpoints, append-only SQL event log with incremental rollup, and server-side release attribution.
|
||||
|
||||
- **Why:** The home hero's Plays stat card (`NowPlayingStats.razor`'s third card) has been a static "XXX / Plays (Coming Soon)" placeholder. Phase 16 builds the anonymous, privacy-light substrate that will eventually power it. Wave 16.1 is the cold-start foundation — nothing reads the log yet; correctness and storage are the deliverable, not the visible metric.
|
||||
|
||||
- **Shape:**
|
||||
- **Client — `PlayTracker`** (`DeepDrftPublic.Client/Services/PlayTracker.cs`): opens a play session on playback start, advances a high-water position on each progress tick (instrumented at `StreamingAudioPlayerService`, not at the HTTP layer, so seek-beyond-buffer re-fetches count as the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3s OR ≥5% of duration (whichever is smaller). Three-bucket classification: `partial` < 30%, `sampled` 30–80%, `complete` > 80%. Emits at most one event per session via `IPlayEventSink`. Deliberately free of player, HTTP, and JS dependencies for testability.
|
||||
- **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce so repeated copies of the same link in a session count as one share. Sends via `sendBeacon`. No `anonId` in wave 16.1.
|
||||
- **Client — `BeaconInterop`** (`DeepDrftPublic.Client/Services/BeaconInterop.cs`): `navigator.sendBeacon` JS interop wrapper + page-unload handler that flushes any pending play event when the page is torn down.
|
||||
- **Public proxy — `EventProxyController`** (`DeepDrftPublic/Controllers/EventProxyController.cs`): proxies `POST api/event/play` and `POST api/event/share` to DeepDrftAPI. Buffers and relays the small JSON body verbatim; forwards `X-Forwarded-For` for per-IP rate limiting on the API side. Opts out of antiforgery (`[IgnoreAntiforgeryToken]`) — `sendBeacon` cannot attach tokens.
|
||||
- **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `POST api/event/play` and `POST api/event/share`, unauthenticated, rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, registered in `Program.cs`). Returns `202 Accepted` (fire-and-forget contract). Payload-validates the track key and enum values; delegates writes to `IEventService`.
|
||||
- **API — rate limiter** (`DeepDrftAPI/Program.cs`): `AddRateLimiter` + `"events"` fixed-window policy keyed on `Connection.RemoteIpAddress`; `UseForwardedHeaders` in production resolves the XFF chain into the real client IP. `UseRateLimiter()` added to the middleware pipeline.
|
||||
- **Data — `EventRepository`** (`DeepDrftData/Repositories/EventRepository.cs`): append-only writes to `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository stamps the release id.
|
||||
- **Data — `EventManager` / `IEventService`** (`DeepDrftData/EventManager.cs`): `IEventService` boundary (`RecordPlay`, `RecordShare`); `EventManager` wraps `EventRepository` and returns NetBlocks `Result`. Registered scoped in `DeepDrftAPI/Program.cs` alongside the existing track and release domain services.
|
||||
- **Migration `20260619155610_AddPlayShareTelemetry`**: adds `play_event`, `share_event`, and `play_counter` tables. **Authored but not yet applied** (Daniel-gated).
|
||||
|
||||
---
|
||||
|
||||
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.3 — Unique-listener `anonId` layer (landed 2026-06-19)
|
||||
|
||||
**Landed:** 2026-06-19 on dev (merge `297805b`). No migration — `anon_id varchar(64)` columns and `IX_play_event_anon_id`/`IX_share_event_anon_id` indexes already shipped in the wave 16.1 migration.
|
||||
|
||||
- **What:** The unique-listener `anonId` seam end-to-end — the "last metric layer" of the Phase 16 substrate. Client mints a first-party `localStorage` GUID on first visit, threads it onto play and share beacon payloads (omitted when null), server accepts and length-clamps it (reject-not-truncate, ≤64 chars), persists it to the reserved nullable `anon_id` columns, and exposes all-time distinct-listener aggregation. The distinct-count capability is in place but not yet surfaced on any read surface (16.5 consumes it). Privacy-notice copy deliberately not authored (Daniel-gated).
|
||||
|
||||
- **Why:** The anonymous unique-listener metric (D5 / D3) is the final substrate wave before the home Plays card can be lit (16.5). It was sequenced last of the metric layers because it is the lowest-priority metric and carries no dependency — the event log captures `anon_id` on the same rows 16.1 already writes; 16.3 simply lights the seam that was reserved but unused.
|
||||
|
||||
- **Shape:**
|
||||
- **Client — `IAnonIdProvider` / `AnonIdProvider`** (`DeepDrftPublic.Client/Services/IAnonIdProvider.cs`, `AnonIdProvider.cs`): `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via JS interop — idempotent, best-effort, never throws). `AnonIdProvider` is the production implementation over the `window.DeepDrftAnonId.get` interop call. Degrades to null when `localStorage` is unavailable (private mode / blocked / partitioned iframe) — missing id is the accepted graceful path; over-counting is the direction of error (§3). Scoped (per-session cache); the token itself outlives the session in `localStorage`.
|
||||
- **Client — TypeScript interop** (`DeepDrftPublic/Interop/telemetry/anonid.ts`): mints and reads the `localStorage` GUID. Exposes `window.DeepDrftAnonId.get`. Returns null without throwing when storage is unavailable.
|
||||
- **Client — `BeaconPlayEventSink`** (`DeepDrftPublic.Client/Services/BeaconPlayEventSink.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId`. Null id produces an anonId-less payload (the field is omitted from the wire JSON entirely via `WhenWritingNull` — the API treats absent and null identically).
|
||||
- **Client — `ShareTracker`** (`DeepDrftPublic.Client/Services/ShareTracker.cs`): now injects `IAnonIdProvider`; reads `_anonId.Current` at share time and sets `ShareEventDto.AnonId`. Same null-omit posture as the play sink.
|
||||
- **API — `EventController`** (`DeepDrftAPI/Controllers/EventController.cs`): `TryNormalizeAnonId` helper on both `POST api/event/play` and `POST api/event/share` — whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars is rejected with `400 Bad Request` rather than truncated (truncation would collide distinct listeners onto one prefix); valid tokens are trimmed and passed through.
|
||||
- **Data — `EventRepository`** (`DeepDrftData/Repositories/EventRepository.cs`): three new distinct-count queries (already in the repository as of 16.3): `CountDistinctListenersAsync()` (site-wide, nulls excluded), `CountDistinctListenersForTrackAsync(trackEntryKey)` (per-track), `CountDistinctListenersForReleaseAsync(releaseId)` (per-release, uses the stamped `release_id` on the play event row — D4 attribution).
|
||||
- **Data — `IEventService` / `EventManager`** (`DeepDrftData/EventManager.cs`): three new members exposing the distinct-count capability: `GetDistinctListenerCount()`, `GetDistinctListenerCountForTrack(trackEntryKey)`, `GetDistinctListenerCountForRelease(releaseId)` — each returns `ResultContainer<int>`. No read surface or card consumes them yet (16.5).
|
||||
- **No migration** — the `anon_id varchar(64)` columns on `play_event` and `share_event` and their covering indexes (`IX_play_event_anon_id`, `IX_share_event_anon_id`) were already created by `20260619155610_AddPlayShareTelemetry` (wave 16.1). Wave 16.3 only wires the client seam and adds the server-side aggregation queries.
|
||||
|
||||
---
|
||||
|
||||
## Phase 16 — Anonymous Play & Share Tracking: Wave 16.2 — Completion-bucket classification + shares (landed by absorption into 16.1)
|
||||
|
||||
**Landed:** absorbed into wave 16.1 (2026-06-19). All §4.1 deliverables shipped inside the foundation wave.
|
||||
|
||||
- **What:** Three-bucket completion classification correct and exhaustive end to end, and share-channel split. Because these were structurally inseparable from the foundation (the tracker, payload, log table, and rollup all required the bucket column set from day one), they landed together with 16.1 rather than as a follow-on wave.
|
||||
|
||||
- **Why:** The §4.2 spec listed bucket classification and share-channel split as wave 16.2 items, but the implementation showed they could not be cleanly deferred — the `play_counter` rollup columns are per-bucket by design (D6), and the share `channel` discriminator is a single non-null column on the `share_event` table. Building the log without them would have required a migration to add them in 16.2 anyway.
|
||||
|
||||
- **Shape:**
|
||||
- **`PlayBucket` enum** (`DeepDrftModels.Enums`): `Partial` (< 30%), `Sampled` (30–80%), `Complete` (> 80%) — exhaustive, non-overlapping. D1 resolved.
|
||||
- **`PlayCounter` rollup columns** (`DeepDrftModels.Entities.PlayCounter`): `PartialCount`, `SampledCount`, `CompleteCount` (each `long`), `TotalPlays` (computed `long` sum). `BumpCounterAsync` in `EventRepository` switches on the bucket to increment the correct column in the same transaction as the event append.
|
||||
- **API-boundary bucket validation** (`EventController`): `Enum.IsDefined(payload.Bucket)` guard — an undefined bucket value returns `400 Bad Request` before the write reaches the repository.
|
||||
- **`ShareChannel` enum** (`DeepDrftModels.Enums`): `Link` / `Embed` on `ShareEvent.Channel`. `ShareTracker` passes the channel through from the `SharePopover` clipboard action; `EventController` validates it is a defined `ShareChannel` value.
|
||||
- **Deferred:** optional `share_count` rollup column on `play_counter` (per-track share count in the rollup table) — not built. Shares are not on the home-card hot path; per-target share reads are speculative wave 16.4 work.
|
||||
|
||||
---
|
||||
|
||||
## Home Hero Stats — Live data wiring (landed 2026-06-18)
|
||||
|
||||
**Landed:** 2026-06-18 on dev (commits `5f0422a` + `8fa330f`, merged `e9e6b60`).
|
||||
|
||||
+28
-4
@@ -157,7 +157,7 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
|
||||
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). Defaults to `Cut` if null or unrecognized.
|
||||
- `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
|
||||
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
|
||||
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`).
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 if the request violates domain cardinality rules (e.g., track number conflict). Returns 500 if processing fails.
|
||||
|
||||
@@ -177,7 +177,7 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL track ID.
|
||||
- **Form field `audioFile`** (`IFormFile`, required): the replacement audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
|
||||
- `[RequestSizeLimit(1 GB)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 1 GB)]` mirror the upload ceiling. The body is streamed to a temp file (correct extension preserved for the audio processor), always deleted in a `finally` block.
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a temp file (correct extension preserved for the audio processor), always deleted in a `finally` block.
|
||||
- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (registers new audio under the existing `EntryKey`; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums (best-effort; a datum failure is logged and swallowed) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale).
|
||||
- Returns 200 on success. Returns 400 if the file is missing or the format is unsupported. Returns 404 if the track id is not found. Returns 500 if vault processing fails.
|
||||
|
||||
@@ -304,8 +304,32 @@ Aggregate figures behind the public home hero stat row (`NowPlayingStats`). A si
|
||||
- `CutReleaseTypeCounts` (`List<CutReleaseTypeCount>`): per-`ReleaseType` Cut release counts; zero-count types are absent (zero-suppressed server-side).
|
||||
- `MixReleaseCount` (int): total non-deleted Mix-medium releases.
|
||||
- `MixRuntimeSeconds` (double): sum of `DurationSeconds` across all non-deleted tracks on Mix releases (null durations count as 0).
|
||||
- Aggregated in `TrackRepository.GetHomeStatsAsync`, surfaced via `ITrackService`/`TrackManager`. Controller is `StatsController` — a thin HTTP boundary; no domain logic lives there.
|
||||
- Returns 200 on success. Returns 500 on query error.
|
||||
- `TotalPlays` (long): site-wide total plays — sum of every `play_counter` row's bucket columns (`PartialCount + SampledCount + CompleteCount`), all-time (Phase 16). Zero until the play-telemetry migration is applied.
|
||||
- `UniqueListeners` (int): site-wide distinct anonymous listeners — distinct non-null `anon_id` across all play events, all-time (Phase 16). Zero until the migration is applied.
|
||||
- `StatsController` injects **both** `ITrackService` (track-domain aggregation — Cuts/Mixes cards) and `IEventService` (event-domain aggregation — Plays card). Neither domain reaches into the other's tables; the controller is the thin composition seam. Track-domain aggregation comes from `TrackRepository.GetHomeStatsAsync` via `ITrackService.GetHomeStats`; play/listener figures come from `IEventService.GetTotalPlayCount` and `IEventService.GetDistinctListenerCount` (Phase 16 wave 16.5). Play/listener reads are **best-effort**: a telemetry failure or not-yet-applied migration leaves those fields at 0 rather than failing the whole endpoint with 500.
|
||||
- Returns 200 on success. Returns 500 if the track-domain aggregation fails.
|
||||
|
||||
## The event endpoints (Phase 16 anonymous telemetry)
|
||||
|
||||
Both endpoints are unauthenticated and rate-limited by the `"events"` fixed-window policy (30 requests / 60 s per IP, keyed on `Connection.RemoteIpAddress` after `UseForwardedHeaders()` resolves XFF). Returns `202 Accepted` — fire-and-forget contract; the `sendBeacon` client ignores the response. Controller: `EventController`.
|
||||
|
||||
### POST api/event/play (unauthenticated, rate-limited)
|
||||
|
||||
Records an anonymous play event. Client sends the track `EntryKey`, a completion bucket, and an optional `anonId` (wave 16.3); server-side release resolution joins track→release at write time (D4). The `anonId` is length-clamped server-side: whitespace-only / empty / null collapses to null (valid anonId-less event); a token longer than 64 chars returns `400` rather than being truncated (truncation would collide distinct listeners).
|
||||
|
||||
- **Body** (`PlayEventDto`): `{ "trackEntryKey": "...", "bucket": "partial"|"sampled"|"complete", "anonId": "..." }` (`anonId` optional — omitted when null).
|
||||
- Validates: non-empty `trackEntryKey`; `bucket` must be a defined `PlayBucket` enum value.
|
||||
- Delegates to `IEventService.RecordPlay`, which appends to `play_event` and bumps `play_counter`.
|
||||
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 when the rate limit is exceeded. Returns 500 on a write failure (logged; beacon ignores it).
|
||||
|
||||
### POST api/event/share (unauthenticated, rate-limited)
|
||||
|
||||
Records an anonymous share event (a clipboard write from `SharePopover`).
|
||||
|
||||
- **Body** (`ShareEventDto`): `{ "targetKey": "...", "targetType": "track"|"release", "channel": "link"|"embed", "anonId": "..." }` (`anonId` optional — omitted when null; same length-clamp as the play endpoint).
|
||||
- Validates: non-empty `targetKey`; defined `ShareTargetType` and `ShareChannel` enum values; `anonId` ≤ 64 chars (reject-not-truncate).
|
||||
- Delegates to `IEventService.RecordShare`, which appends to `share_event`.
|
||||
- Returns 202 on success. Returns 400 for missing/invalid fields. Returns 429 on rate limit. Returns 500 on write failure.
|
||||
|
||||
## ApiKey middleware behaviour
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
using DeepDrftData;
|
||||
using DeepDrftModels.DTOs;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace DeepDrftAPI.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous play/share telemetry intake (Phase 16 §2.2 / §4.3). Unauthenticated — same posture as the
|
||||
/// public reads — but IP rate-limited (the "events" limiter, registered in Program.cs) and payload-
|
||||
/// validated to make casual inflation annoying (§2.5). Both endpoints return <c>202 Accepted</c>: these
|
||||
/// are fire-and-forget telemetry, not transactions, and the client (a <c>sendBeacon</c>) never reads the
|
||||
/// response. The release dimension on a play is resolved server-side from the track key (§2.3 / D4).
|
||||
/// The controller is a thin HTTP boundary; all write logic lives in <see cref="IEventService"/>.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/event")]
|
||||
[EnableRateLimiting("events")]
|
||||
public class EventController : ControllerBase
|
||||
{
|
||||
// Reject oversized bodies before deserialization — a coarse abuse guard (§2.5). The legitimate
|
||||
// payloads are a track key + an enum, well under 1 KB.
|
||||
private const int MaxBodyBytes = 1024;
|
||||
|
||||
// The anonId is a client-minted GUID string (~36 chars); the anon_id column is varchar(64). Reject
|
||||
// anything longer as malformed rather than silently truncating — an over-long token is either a bug
|
||||
// or an inflation attempt, and a truncated id would corrupt the distinct-listener count by colliding
|
||||
// distinct listeners onto one prefix. Whitespace-only is treated as absent.
|
||||
private const int MaxAnonIdLength = 64;
|
||||
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ILogger<EventController> _logger;
|
||||
|
||||
public EventController(IEventService eventService, ILogger<EventController> logger)
|
||||
{
|
||||
_eventService = eventService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// POST api/event/play (unauthenticated, rate-limited)
|
||||
[HttpPost("play")]
|
||||
[RequestSizeLimit(MaxBodyBytes)]
|
||||
public async Task<ActionResult> RecordPlay([FromBody] PlayEventDto payload, CancellationToken ct = default)
|
||||
{
|
||||
// Reject a missing track key and an out-of-range bucket (§2.5). [ApiController] model binding
|
||||
// already 400s a malformed/oversized body and an undefined enum value, but the explicit guards
|
||||
// keep the contract obvious and cover the empty-string key the model binder lets through.
|
||||
if (string.IsNullOrWhiteSpace(payload.TrackEntryKey))
|
||||
return BadRequest("trackEntryKey is required");
|
||||
if (!Enum.IsDefined(payload.Bucket))
|
||||
return BadRequest("bucket is invalid");
|
||||
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
|
||||
return BadRequest("anonId is invalid");
|
||||
|
||||
var result = await _eventService.RecordPlay(payload.TrackEntryKey, payload.Bucket, anonId, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
// A telemetry failure must never surface to the listener as an error they can act on, but
|
||||
// we still log it and answer 5xx so a monitor can see the substrate is unhealthy. The
|
||||
// beacon ignores the status either way.
|
||||
_logger.LogWarning("RecordPlay failed: {Error}", result.Messages.FirstOrDefault()?.Message);
|
||||
return StatusCode(500);
|
||||
}
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
// POST api/event/share (unauthenticated, rate-limited)
|
||||
[HttpPost("share")]
|
||||
[RequestSizeLimit(MaxBodyBytes)]
|
||||
public async Task<ActionResult> RecordShare([FromBody] ShareEventDto payload, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload.TargetKey))
|
||||
return BadRequest("targetKey is required");
|
||||
if (!Enum.IsDefined(payload.TargetType))
|
||||
return BadRequest("targetType is invalid");
|
||||
if (!Enum.IsDefined(payload.Channel))
|
||||
return BadRequest("channel is invalid");
|
||||
if (!TryNormalizeAnonId(payload.AnonId, out var anonId))
|
||||
return BadRequest("anonId is invalid");
|
||||
|
||||
var result = await _eventService.RecordShare(payload.TargetType, payload.TargetKey, payload.Channel, anonId, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
_logger.LogWarning("RecordShare failed: {Error}", result.Messages.FirstOrDefault()?.Message);
|
||||
return StatusCode(500);
|
||||
}
|
||||
|
||||
return Accepted();
|
||||
}
|
||||
|
||||
// Normalize an incoming anonId (wave 16.3): whitespace-only / empty / null collapses to a null token
|
||||
// (the listener didn't send one, or storage was unavailable — a valid, anonId-less event). A token
|
||||
// over the column width is rejected (400) rather than truncated, since truncation would collide
|
||||
// distinct listeners. Returns false only on the over-long case; null and a valid token both pass.
|
||||
private static bool TryNormalizeAnonId(string? raw, out string? anonId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
anonId = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
var trimmed = raw.Trim();
|
||||
if (trimmed.Length > MaxAnonIdLength)
|
||||
{
|
||||
anonId = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
anonId = trimmed;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8,29 +8,52 @@ namespace DeepDrftAPI.Controllers;
|
||||
public class StatsController : ControllerBase
|
||||
{
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly IEventService _eventService;
|
||||
private readonly ILogger<StatsController> _logger;
|
||||
|
||||
public StatsController(ITrackService sqlTrackService, ILogger<StatsController> logger)
|
||||
public StatsController(
|
||||
ITrackService sqlTrackService, IEventService eventService, ILogger<StatsController> logger)
|
||||
{
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_eventService = eventService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// GET api/stats/home (unauthenticated)
|
||||
// Aggregate figures behind the public home hero stat row — one read for all three cards. Same auth
|
||||
// posture as the other public browse reads (GET api/track/page). The aggregation lives in the SQL
|
||||
// service/repository; this controller stays a thin HTTP boundary.
|
||||
// posture as the other public browse reads (GET api/track/page). The figures span two domains:
|
||||
// the track-domain aggregation (Cuts/Mixes cards) lives in the SQL track service; the play-domain
|
||||
// figures (Phase 16 Plays card — total plays + unique listeners) live in the event service. This
|
||||
// controller is the thin composition seam that assembles both into one HomeStatsDto — neither
|
||||
// domain reaches into the other's tables. Play/listener figures are best-effort: a telemetry read
|
||||
// failure (or the not-yet-applied migration) leaves them at zero rather than failing the whole card.
|
||||
[HttpGet("home")]
|
||||
public async Task<ActionResult> GetHome(CancellationToken ct = default)
|
||||
{
|
||||
var result = await _sqlTrackService.GetHomeStats(ct);
|
||||
if (!result.Success || result.Value is null)
|
||||
var trackResult = await _sqlTrackService.GetHomeStats(ct);
|
||||
if (!trackResult.Success || trackResult.Value is null)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
var error = trackResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("GetHome stats failed: {Error}", error);
|
||||
return StatusCode(500, "Failed to load stats");
|
||||
}
|
||||
|
||||
return Ok(result.Value);
|
||||
var stats = trackResult.Value;
|
||||
|
||||
var playsResult = await _eventService.GetTotalPlayCount(ct);
|
||||
if (playsResult is { Success: true })
|
||||
stats.TotalPlays = playsResult.Value;
|
||||
else
|
||||
_logger.LogWarning("GetHome total-plays read failed; Plays card falls back to 0: {Error}",
|
||||
playsResult.Messages.FirstOrDefault()?.Message);
|
||||
|
||||
var listenersResult = await _eventService.GetDistinctListenerCount(ct);
|
||||
if (listenersResult is { Success: true })
|
||||
stats.UniqueListeners = listenersResult.Value;
|
||||
else
|
||||
_logger.LogWarning("GetHome unique-listeners read failed; secondary line falls back to 0: {Error}",
|
||||
listenersResult.Messages.FirstOrDefault()?.Message);
|
||||
|
||||
return Ok(stats);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,14 +198,15 @@ public class TrackController : ControllerBase
|
||||
// proxies the upload here so it never touches the vault disk path or SQL directly.
|
||||
// UnifiedTrackService owns the two-database write.
|
||||
//
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to 1 GB: audio uploads can be tens to hundreds
|
||||
// of MB and the framework defaults (~28 MB) reject them outright. The IFormFile path streams
|
||||
// the body to a temp file once Kestrel surfaces it, so the limit is the per-request ceiling,
|
||||
// not a buffered allocation.
|
||||
// RequestSizeLimit/MultipartBodyLengthLimit set to ~1.86 GB: audio uploads can be tens to
|
||||
// hundreds of MB (or over a GB for high-res WAVs); the framework defaults (~28 MB) reject them
|
||||
// outright. The IFormFile path streams the body to a temp file once Kestrel surfaces it, so the
|
||||
// limit is the per-request ceiling, not a buffered allocation. 2_000_000_000 stays below
|
||||
// int.MaxValue (2,147,483,647) so it is safe where limits are int-typed.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("upload")]
|
||||
[RequestSizeLimit(1_073_741_824)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
|
||||
[RequestSizeLimit(2_000_000_000)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
|
||||
public async Task<ActionResult<DeepDrftModels.DTOs.TrackDto>> UploadTrack(
|
||||
[FromForm] IFormFile? audioFile,
|
||||
[FromForm] string? trackName,
|
||||
@@ -503,13 +504,13 @@ public class TrackController : ControllerBase
|
||||
// Swap an existing track's audio bytes from a raw upload, preserving the track's id, EntryKey,
|
||||
// release membership, position, and metadata. UnifiedTrackService.ReplaceAudioAsync owns the
|
||||
// vault swap + waveform regen; nothing in SQL is written. Mirrors the upload endpoint's temp-file
|
||||
// streaming and 1 GB ceiling (a WAV replace is a large-body upload like the original). The
|
||||
// streaming and ~1.86 GB ceiling (a WAV replace is a large-body upload like the original). The
|
||||
// literal "{id:long}/replace-audio" segment is declared in the literal-route block so it never
|
||||
// resolves to the parameterized "{trackId}" GET.
|
||||
[ApiKeyAuthorize]
|
||||
[HttpPost("{id:long}/replace-audio")]
|
||||
[RequestSizeLimit(1_073_741_824)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 1_073_741_824)]
|
||||
[RequestSizeLimit(2_000_000_000)]
|
||||
[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]
|
||||
public async Task<ActionResult> ReplaceAudio(
|
||||
long id,
|
||||
[FromForm] IFormFile? audioFile,
|
||||
|
||||
@@ -8,8 +8,10 @@ using DeepDrftData;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NetBlocks.Utilities.Environment;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
// Required credential files — must exist before the app will start.
|
||||
// Production secrets stay gitignored; the *.example.json templates at the project root show the shape.
|
||||
@@ -64,6 +66,14 @@ builder.Services
|
||||
.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
builder.Services.AddScoped<UnifiedTrackService>();
|
||||
|
||||
// Phase 16 anonymous telemetry — append-only event logs + incremental play-counter rollup (all SQL).
|
||||
// EventManager is the IEventService boundary; EventRepository owns the EF writes and the
|
||||
// release-resolution + counter-bump transaction.
|
||||
builder.Services
|
||||
.AddScoped<EventRepository>()
|
||||
.AddScoped<EventManager>()
|
||||
.AddScoped<IEventService>(sp => sp.GetRequiredService<EventManager>());
|
||||
|
||||
// Release domain — medium-aware read projection + satellite metadata writes. ReleaseManager is the
|
||||
// IReleaseService implementation; UnifiedReleaseService orchestrates the vault + SQL satellite writes.
|
||||
builder.Services
|
||||
@@ -118,6 +128,25 @@ builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
// Per-IP rate limiting for the anonymous telemetry intake (Phase 16 §2.5). Coarse and stateless —
|
||||
// a fixed window keyed by the (forwarded) remote IP. The substrate sits behind nginx, so the real
|
||||
// client IP is the X-Forwarded-For value UseForwardedHeaders resolves into Connection.RemoteIpAddress.
|
||||
// On limit, reject with 429 (the beacon ignores it; this only blunts casual inflation). The 30-window
|
||||
// budget is generous for a real listening session and only bites on scripted spam.
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("events", httpContext =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 30,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Apply AuthBlocks EF migrations, seed system roles, seed admin user on first boot.
|
||||
@@ -136,6 +165,11 @@ if (app.Environment.IsDevelopment())
|
||||
|
||||
app.UseCors("ContentApiPolicy");
|
||||
|
||||
// Rate limiter must sit in the pipeline for the [EnableRateLimiting("events")] attribute on
|
||||
// EventController to take effect. Only the telemetry endpoints carry the policy; everything else is
|
||||
// unaffected (no global limiter is set).
|
||||
app.UseRateLimiter();
|
||||
|
||||
// ApiKey middleware only enforces on endpoints tagged [ApiKeyAuthorize] (the track surface); it
|
||||
// passes all other endpoints through. JWT auth/authorization gate the AuthBlocks endpoints, which
|
||||
// carry no [ApiKeyAuthorize] metadata — the two schemes are orthogonal and do not interfere.
|
||||
|
||||
+12
-5
@@ -20,7 +20,7 @@ Separating domain logic from hosts so DeepDrftAPI can reuse `TrackManager` / `Tr
|
||||
DeepDrftData/
|
||||
├── Data/
|
||||
│ ├── DeepDrftContext.cs # EF DbContext
|
||||
│ ├── DeepDrftContextFactory.cs # Design-time factory (hard-codes ../Database/deepdrft.db)
|
||||
│ ├── DeepDrftContextFactory.cs # Design-time factory (reads environment/connections.json; Npgsql dummy fallback)
|
||||
│ └── Configurations/
|
||||
│ └── TrackConfiguration.cs # EF fluent configuration for TrackEntity
|
||||
├── Migrations/ # EF-generated migrations (namespace DeepDrftData.Migrations)
|
||||
@@ -32,7 +32,7 @@ DeepDrftData/
|
||||
|
||||
## EF DbContext and configuration
|
||||
|
||||
`DeepDrftContext` targets SQLite, connection string from `appsettings.json` (`ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) hard-codes `../Database/deepdrft.db` for `dotnet ef` commands, so you can run migrations locally without a full app context.
|
||||
`DeepDrftContext` targets **PostgreSQL** (Npgsql), connection string from `environment/connections.json` (loaded at runtime via `CredentialTools.ResolvePathOrThrow("connections", ...)` in `DeepDrftAPI/Program.cs`, key `ConnectionStrings:DefaultConnection`). The design-time factory (`DeepDrftContextFactory`) reads the same `environment/connections.json` when present and falls back to a Npgsql dummy connection string (`Host=localhost;Database=deepdrft-design-time;Username=dummy`) for CI or environments without the file, so `dotnet ef` commands work without a live database.
|
||||
|
||||
`TrackConfiguration` uses EF fluent API:
|
||||
- Table name: `track` (singular)
|
||||
@@ -56,6 +56,13 @@ Notable repository / service methods beyond the standard CRUD:
|
||||
- `TrackRepository.UpdateDurationAsync` / `ITrackService.UpdateDuration`: Null-guarded duration write — skips rows where `DurationSeconds` is already set. Used by the one-time backfill (`POST api/track/duration/backfill`).
|
||||
- `TrackRepository.SetDurationAsync` / `ITrackService.SetDuration`: Unconditional duration overwrite — no null guard, always stamps the new value. Used by the replace-audio path (`POST api/track/{id:long}/replace-audio`) where the existing non-null duration must be overwritten with the new audio's value. Returns a fail result when zero rows are affected (track removed between lookup and write).
|
||||
|
||||
## Phase 16 — anonymous telemetry domain (EventRepository / EventManager)
|
||||
|
||||
`EventRepository` and `EventManager` (with `IEventService` boundary) are the SQL-side domain for anonymous play/share telemetry (Phase 16 waves 16.1 + 16.3). Unlike `TrackRepository`, these entities have no soft-delete lifecycle and are not `BaseEntity`/`IEntity` — `EventRepository` is a plain context-backed repository against the same scoped `DeepDrftContext`.
|
||||
|
||||
- **`EventRepository`** (`Repositories/EventRepository.cs`): append-only writes to the `play_event` and `share_event` tables; incremental-on-write bump of the `play_counter` rollup (D6); server-side track→release resolution at write time (D4) — the client sends only the track `EntryKey`, the repository joins track→release and stamps the `release_id` on the row. Also owns the three distinct-listener aggregation queries added in wave 16.3: `CountDistinctListenersAsync()` (site-wide), `CountDistinctListenersForTrackAsync(trackEntryKey)`, `CountDistinctListenersForReleaseAsync(releaseId)` — each excludes null `anon_id` rows.
|
||||
- **`EventManager` / `IEventService`** (`EventManager.cs`): `RecordPlay(trackEntryKey, bucket, anonId, ct)` and `RecordShare(targetType, targetKey, channel, anonId, ct)` return NetBlocks `Result`. Wave 16.3 added three distinct-count members returning `ResultContainer<int>`: `GetDistinctListenerCount()`, `GetDistinctListenerCountForTrack(trackEntryKey)`, `GetDistinctListenerCountForRelease(releaseId)`. Registered scoped in `DeepDrftAPI/Program.cs`. Migration: `20260619155610_AddPlayShareTelemetry` (authored; not yet applied — Daniel-gated). The `anon_id` columns and covering indexes on `play_event`/`share_event` are part of this migration — no additional migration was needed for 16.3.
|
||||
|
||||
Example:
|
||||
|
||||
```csharp
|
||||
@@ -139,9 +146,9 @@ Migrations live in the `DeepDrftData.Migrations` namespace. Migration files are
|
||||
## Connection string
|
||||
|
||||
- **DeepDrftAPI**: `environment/connections.json` → `ConnectionStrings:DefaultConnection`
|
||||
- Points at the same database (PostgreSQL in production, SQLite for local development).
|
||||
- Always PostgreSQL (Npgsql) — both production and local development.
|
||||
|
||||
The design-time factory hard-codes the local path for `dotnet ef` commands.
|
||||
The design-time factory reads `environment/connections.json` when present; falls back to a Npgsql dummy for CI.
|
||||
|
||||
## Service registration
|
||||
|
||||
@@ -149,7 +156,7 @@ In `DeepDrftAPI/Program.cs`:
|
||||
|
||||
```csharp
|
||||
services.AddDbContext<DeepDrftContext>(options =>
|
||||
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection"))); // or UseSqlite for dev
|
||||
options.UseNpgsql(configuration.GetConnectionString("DefaultConnection")));
|
||||
services.AddScoped<TrackRepository>();
|
||||
services.AddScoped<TrackManager>();
|
||||
services.AddScoped<ITrackService>(sp => sp.GetRequiredService<TrackManager>());
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the <c>play_counter</c> rollup (Phase 16 §4.1 / D6). One row per track, unique
|
||||
/// on track_id so the incremental-on-write bump is an upsert against a single row. <c>TotalPlays</c> is
|
||||
/// a computed C# property (sum of the three bucket columns) and is not mapped — it is derived on read.
|
||||
/// </summary>
|
||||
public class PlayCounterConfiguration : IEntityTypeConfiguration<PlayCounter>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayCounter> builder)
|
||||
{
|
||||
builder.ToTable("play_counter");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
builder.Property(e => e.TrackId)
|
||||
.IsRequired()
|
||||
.HasColumnName("track_id");
|
||||
|
||||
builder.Property(e => e.PartialCount)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("partial_count");
|
||||
|
||||
builder.Property(e => e.SampledCount)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("sampled_count");
|
||||
|
||||
builder.Property(e => e.CompleteCount)
|
||||
.IsRequired()
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("complete_count");
|
||||
|
||||
// Derived headline figure — never a column.
|
||||
builder.Ignore(e => e.TotalPlays);
|
||||
|
||||
builder.HasIndex(e => e.TrackId)
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_play_counter_track_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the append-only <c>play_event</c> log (Phase 16 §4.2). Plain entity, not a
|
||||
/// <c>BaseEntity</c> — no soft-delete or updated_at, just an immutable fact with a created_at stamp.
|
||||
/// Indexed on track key, release id, and anon id (the last reserved for the wave-16.3 distinct-listener
|
||||
/// query) so the aggregation paths stay cheap as the log grows.
|
||||
/// </summary>
|
||||
public class PlayEventConfiguration : IEntityTypeConfiguration<PlayEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<PlayEvent> builder)
|
||||
{
|
||||
builder.ToTable("play_event");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
builder.Property(e => e.TrackEntryKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("track_entry_key");
|
||||
|
||||
builder.Property(e => e.ReleaseId)
|
||||
.HasColumnName("release_id");
|
||||
|
||||
builder.Property(e => e.Bucket)
|
||||
.IsRequired()
|
||||
.HasConversion<string>() // Store the readable bucket name, mirroring ReleaseMedium.
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("bucket");
|
||||
|
||||
// Reserved nullable token (wave 16.3). Same width as a stringified GUID.
|
||||
builder.Property(e => e.AnonId)
|
||||
.HasMaxLength(64)
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
builder.Property(e => e.CreatedAt)
|
||||
.IsRequired()
|
||||
.HasColumnName("created_at");
|
||||
|
||||
builder.HasIndex(e => e.TrackEntryKey).HasDatabaseName("IX_play_event_track_entry_key");
|
||||
builder.HasIndex(e => e.ReleaseId).HasDatabaseName("IX_play_event_release_id");
|
||||
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_play_event_anon_id");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
using DeepDrftModels.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace DeepDrftData.Data.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// EF configuration for the append-only <c>share_event</c> log (Phase 16 §4.2). Plain immutable-fact
|
||||
/// entity. Indexed on the target key so per-target share tallies stay cheap.
|
||||
/// </summary>
|
||||
public class ShareEventConfiguration : IEntityTypeConfiguration<ShareEvent>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ShareEvent> builder)
|
||||
{
|
||||
builder.ToTable("share_event");
|
||||
|
||||
builder.HasKey(e => e.Id);
|
||||
builder.Property(e => e.Id).HasColumnName("id");
|
||||
|
||||
builder.Property(e => e.TargetType)
|
||||
.IsRequired()
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("target_type");
|
||||
|
||||
builder.Property(e => e.TargetKey)
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnName("target_key");
|
||||
|
||||
builder.Property(e => e.Channel)
|
||||
.IsRequired()
|
||||
.HasConversion<string>()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnName("channel");
|
||||
|
||||
builder.Property(e => e.AnonId)
|
||||
.HasMaxLength(64)
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
builder.Property(e => e.CreatedAt)
|
||||
.IsRequired()
|
||||
.HasColumnName("created_at");
|
||||
|
||||
builder.HasIndex(e => e.TargetKey).HasDatabaseName("IX_share_event_target_key");
|
||||
builder.HasIndex(e => e.AnonId).HasDatabaseName("IX_share_event_anon_id");
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@ public class DeepDrftContext : DbContext
|
||||
public DbSet<SessionMetadata> SessionMetadata { get; set; }
|
||||
public DbSet<MixMetadata> MixMetadata { get; set; }
|
||||
|
||||
// Phase 16 anonymous telemetry: append-only event logs + incremental play rollup. All SQL — the
|
||||
// FileDatabase vault is not involved.
|
||||
public DbSet<PlayEvent> PlayEvents { get; set; }
|
||||
public DbSet<ShareEvent> ShareEvents { get; set; }
|
||||
public DbSet<PlayCounter> PlayCounters { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -23,5 +29,8 @@ public class DeepDrftContext : DbContext
|
||||
modelBuilder.ApplyConfiguration(new ReleaseConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SessionMetadataConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new MixMetadataConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new PlayEventConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new ShareEventConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new PlayCounterConfiguration());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftData;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IEventService"/> implementation over <see cref="EventRepository"/>. The layer boundary
|
||||
/// matches the rest of DeepDrftData: the repository owns the EF constructs and the write transaction;
|
||||
/// this service catches at the boundary and returns a NetBlocks <see cref="Result"/>. Telemetry is
|
||||
/// best-effort by design (§2.2) — a failed write is logged and surfaced as a fail result, never thrown
|
||||
/// at the caller, so a telemetry hiccup can never reach a listener.
|
||||
/// </summary>
|
||||
public class EventManager : IEventService
|
||||
{
|
||||
private readonly EventRepository _repository;
|
||||
private readonly ILogger<EventManager> _logger;
|
||||
|
||||
public EventManager(EventRepository repository, ILogger<EventManager> logger)
|
||||
{
|
||||
_repository = repository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result> RecordPlay(
|
||||
string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _repository.RecordPlayAsync(trackEntryKey, bucket, anonId, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to record play event for track {TrackEntryKey}", trackEntryKey);
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> RecordShare(
|
||||
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _repository.RecordShareAsync(targetType, targetKey, channel, anonId, cancellationToken);
|
||||
return Result.CreatePassResult();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to record share event for {TargetType} {TargetKey}", targetType, targetKey);
|
||||
return Result.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<long>> GetTotalPlayCount(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _repository.CountTotalPlaysAsync(cancellationToken);
|
||||
return ResultContainer<long>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to count total plays");
|
||||
return ResultContainer<long>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _repository.CountDistinctListenersAsync(cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to count distinct listeners");
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> GetDistinctListenerCountForTrack(
|
||||
string trackEntryKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _repository.CountDistinctListenersForTrackAsync(trackEntryKey, cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to count distinct listeners for track {TrackEntryKey}", trackEntryKey);
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<int>> GetDistinctListenerCountForRelease(
|
||||
long releaseId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var count = await _repository.CountDistinctListenersForReleaseAsync(releaseId, cancellationToken);
|
||||
return ResultContainer<int>.CreatePassResult(count);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "Failed to count distinct listeners for release {ReleaseId}", releaseId);
|
||||
return ResultContainer<int>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftData;
|
||||
|
||||
/// <summary>
|
||||
/// SQL-side anonymous telemetry service (Phase 16). Records play and share events to the append-only
|
||||
/// logs and maintains the incremental play-counter rollup. The release dimension on a play is resolved
|
||||
/// server-side from the track key (§2.3 / D4) — callers pass only what the client cheaply knows.
|
||||
/// Returns NetBlocks <see cref="Result"/> at the boundary; the controller maps that to 202/4xx/5xx.
|
||||
/// </summary>
|
||||
public interface IEventService
|
||||
{
|
||||
/// <summary>
|
||||
/// Record one play: append a <c>play_event</c> row (release resolved from the track key) and bump
|
||||
/// the track's <c>play_counter</c> in the same transaction. A play of an unknown/removed track key
|
||||
/// still logs (with a null release and no counter bump) rather than failing.
|
||||
/// </summary>
|
||||
Task<Result> RecordPlay(string trackEntryKey, PlayBucket bucket, string? anonId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Record one share: append a <c>share_event</c> row. Target and channel come straight from the client.</summary>
|
||||
Task<Result> RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Site-wide total play count (Phase 16 §5 — all-time): the sum of every <c>play_counter</c> row's
|
||||
/// three bucket columns. Zero until the telemetry migration is applied. The home Plays card's primary
|
||||
/// figure; the controller composes it onto <c>HomeStatsDto</c> alongside the track-domain figures.
|
||||
/// </summary>
|
||||
Task<ResultContainer<long>> GetTotalPlayCount(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Site-wide distinct-listener count (Phase 16 §3, D3 — all-time): distinct non-null <c>anon_id</c>
|
||||
/// values across all play events. Null tokens are excluded (not a known listener). The capability for
|
||||
/// wave 16.5's "N listeners" card; nothing surfaces it via API or UI in wave 16.3.
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> GetDistinctListenerCount(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Distinct listeners who played the given track (by vault entry key). Null tokens excluded.</summary>
|
||||
Task<ResultContainer<int>> GetDistinctListenerCountForTrack(string trackEntryKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Distinct listeners across the release's tracks (derived, D4) — a listener who played any track in
|
||||
/// the release counts once. Null tokens excluded.
|
||||
/// </summary>
|
||||
Task<ResultContainer<int>> GetDistinctListenerCountForRelease(long releaseId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DeepDrftData.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
[DbContext(typeof(DeepDrftContext))]
|
||||
[Migration("20260619155610_AddPlayShareTelemetry")]
|
||||
partial class AddPlayShareTelemetry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.Property<string>("WaveformEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("waveform_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_mix_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_mix_metadata_release_id");
|
||||
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.PlayCounter", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("CompleteCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("complete_count");
|
||||
|
||||
b.Property<long>("PartialCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("partial_count");
|
||||
|
||||
b.Property<long>("SampledCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("sampled_count");
|
||||
|
||||
b.Property<long>("TrackId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("track_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrackId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_play_counter_track_id");
|
||||
|
||||
b.ToTable("play_counter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Bucket")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("bucket");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("track_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnonId")
|
||||
.HasDatabaseName("IX_play_event_anon_id");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.HasDatabaseName("IX_play_event_release_id");
|
||||
|
||||
b.HasIndex("TrackEntryKey")
|
||||
.HasDatabaseName("IX_play_event_track_entry_key");
|
||||
|
||||
b.ToTable("play_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("Artist")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("artist");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("CreatedByUserId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("created_by_user_id");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("genre");
|
||||
|
||||
b.Property<string>("ImagePath")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("image_path");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("Medium")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Cut")
|
||||
.HasColumnName("medium");
|
||||
|
||||
b.Property<DateOnly?>("ReleaseDate")
|
||||
.HasColumnType("date")
|
||||
.HasColumnName("release_date");
|
||||
|
||||
b.Property<string>("ReleaseType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasDefaultValue("Single")
|
||||
.HasColumnName("release_type");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EntryKey")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_entry_key");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_release_is_deleted");
|
||||
|
||||
b.HasIndex("Title", "Artist")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_release_title_artist")
|
||||
.HasFilter("\"is_deleted\" = false");
|
||||
|
||||
b.ToTable("release", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("HeroImageEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("hero_image_entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<long>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_session_metadata_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_session_metadata_release_id");
|
||||
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ShareEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("TargetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("target_key");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("target_type");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnonId")
|
||||
.HasDatabaseName("IX_share_event_anon_id");
|
||||
|
||||
b.HasIndex("TargetKey")
|
||||
.HasDatabaseName("IX_share_event_target_key");
|
||||
|
||||
b.ToTable("share_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<double?>("DurationSeconds")
|
||||
.HasColumnType("double precision")
|
||||
.HasColumnName("duration_seconds");
|
||||
|
||||
b.Property<string>("EntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("entry_key");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("boolean")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<string>("OriginalFileName")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("original_file_name");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)")
|
||||
.HasColumnName("track_name");
|
||||
|
||||
b.Property<int>("TrackNumber")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("track_number");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsDeleted")
|
||||
.HasDatabaseName("IX_track_is_deleted");
|
||||
|
||||
b.HasIndex("ReleaseId");
|
||||
|
||||
b.ToTable("track", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.MixMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("MixMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.MixMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.SessionMetadata", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithOne("SessionMetadata")
|
||||
.HasForeignKey("DeepDrftModels.Entities.SessionMetadata", "ReleaseId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.HasOne("DeepDrftModels.Entities.ReleaseEntity", "Release")
|
||||
.WithMany("Tracks")
|
||||
.HasForeignKey("ReleaseId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Release");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Navigation("MixMetadata");
|
||||
|
||||
b.Navigation("SessionMetadata");
|
||||
|
||||
b.Navigation("Tracks");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DeepDrftData.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPlayShareTelemetry : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "play_counter",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
track_id = table.Column<long>(type: "bigint", nullable: false),
|
||||
partial_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
|
||||
sampled_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L),
|
||||
complete_count = table.Column<long>(type: "bigint", nullable: false, defaultValue: 0L)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_play_counter", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "play_event",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
track_entry_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
release_id = table.Column<long>(type: "bigint", nullable: true),
|
||||
bucket = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_play_event", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "share_event",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<long>(type: "bigint", nullable: false)
|
||||
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
|
||||
target_type = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
target_key = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
channel = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
|
||||
anon_id = table.Column<string>(type: "character varying(64)", maxLength: 64, nullable: true),
|
||||
created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_share_event", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_counter_track_id",
|
||||
table: "play_counter",
|
||||
column: "track_id",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_event_anon_id",
|
||||
table: "play_event",
|
||||
column: "anon_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_event_release_id",
|
||||
table: "play_event",
|
||||
column: "release_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_play_event_track_entry_key",
|
||||
table: "play_event",
|
||||
column: "track_entry_key");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_share_event_anon_id",
|
||||
table: "share_event",
|
||||
column: "anon_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_share_event_target_key",
|
||||
table: "share_event",
|
||||
column: "target_key");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "play_counter");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "play_event");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "share_event");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,94 @@ namespace DeepDrftData.Migrations
|
||||
b.ToTable("mix_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.PlayCounter", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<long>("CompleteCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("complete_count");
|
||||
|
||||
b.Property<long>("PartialCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("partial_count");
|
||||
|
||||
b.Property<long>("SampledCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasDefaultValue(0L)
|
||||
.HasColumnName("sampled_count");
|
||||
|
||||
b.Property<long>("TrackId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("track_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TrackId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_play_counter_track_id");
|
||||
|
||||
b.ToTable("play_counter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.PlayEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Bucket")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("bucket");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<long?>("ReleaseId")
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("release_id");
|
||||
|
||||
b.Property<string>("TrackEntryKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("track_entry_key");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnonId")
|
||||
.HasDatabaseName("IX_play_event_anon_id");
|
||||
|
||||
b.HasIndex("ReleaseId")
|
||||
.HasDatabaseName("IX_play_event_release_id");
|
||||
|
||||
b.HasIndex("TrackEntryKey")
|
||||
.HasDatabaseName("IX_play_event_track_entry_key");
|
||||
|
||||
b.ToTable("play_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ReleaseEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -209,6 +297,53 @@ namespace DeepDrftData.Migrations
|
||||
b.ToTable("session_metadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.ShareEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bigint")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
|
||||
|
||||
b.Property<string>("AnonId")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("anon_id");
|
||||
|
||||
b.Property<string>("Channel")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("channel");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("TargetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("target_key");
|
||||
|
||||
b.Property<string>("TargetType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)")
|
||||
.HasColumnName("target_type");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AnonId")
|
||||
.HasDatabaseName("IX_share_event_anon_id");
|
||||
|
||||
b.HasIndex("TargetKey")
|
||||
.HasDatabaseName("IX_share_event_target_key");
|
||||
|
||||
b.ToTable("share_event", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DeepDrftModels.Entities.TrackEntity", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DeepDrftData.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Data access for the Phase 16 anonymous telemetry tables (all SQL — the FileDatabase vault is not
|
||||
/// involved). Owns the append-only writes to <c>play_event</c> / <c>share_event</c> and the
|
||||
/// incremental-on-write bump of the <c>play_counter</c> rollup (D6). Server-side release resolution
|
||||
/// (§2.3 / D4) lives here: a play event carries only the track key, and this repository joins
|
||||
/// track→release at write time and stamps the release id on the row.
|
||||
///
|
||||
/// <para>
|
||||
/// Unlike <see cref="TrackRepository"/> these entities are not <c>BaseEntity</c>/<c>IEntity</c> (no
|
||||
/// soft-delete lifecycle), so this is a plain context-backed repository rather than an extension of the
|
||||
/// BlazorBlocks <c>Repository<></c> base. It holds the same scoped <see cref="DeepDrftContext"/>
|
||||
/// the rest of the SQL layer uses, never a service locator.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class EventRepository
|
||||
{
|
||||
private readonly DeepDrftContext _context;
|
||||
|
||||
public EventRepository(DeepDrftContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Append one play event and bump the track's counter in a single transaction (D6). The release id
|
||||
/// is resolved here from the track key (§2.3 / D4): a live track contributes its release id (null
|
||||
/// for a loose track); an unknown key records the event with a null release and no counter bump
|
||||
/// (there is no track to roll up against). Returns true when the event was written.
|
||||
/// </summary>
|
||||
public async Task<bool> RecordPlayAsync(
|
||||
string trackEntryKey, PlayBucket bucket, string? anonId, CancellationToken ct = default)
|
||||
{
|
||||
// Resolve the track→release link server-side. Soft-deleted tracks resolve to null so a play of
|
||||
// a since-removed track still logs (with no counter bump) rather than throwing.
|
||||
var track = await _context.Tracks
|
||||
.Where(t => t.EntryKey == trackEntryKey && !t.IsDeleted)
|
||||
.Select(t => new { t.Id, t.ReleaseId })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
// The append and the counter bump must commit together — wrap them in one transaction so a
|
||||
// counter that drifts from the log is impossible. Reuse an ambient transaction if the caller
|
||||
// already opened one.
|
||||
var ownsTransaction = _context.Database.CurrentTransaction is null;
|
||||
var transaction = ownsTransaction
|
||||
? await _context.Database.BeginTransactionAsync(ct)
|
||||
: null;
|
||||
try
|
||||
{
|
||||
_context.PlayEvents.Add(new PlayEvent
|
||||
{
|
||||
TrackEntryKey = trackEntryKey,
|
||||
ReleaseId = track?.ReleaseId,
|
||||
Bucket = bucket,
|
||||
AnonId = anonId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
|
||||
if (track is not null)
|
||||
await BumpCounterAsync(track.Id, bucket, ct);
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
if (transaction is not null)
|
||||
await transaction.CommitAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.RollbackAsync(ct);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (transaction is not null)
|
||||
await transaction.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Site-wide total plays: the sum of every counter's three bucket columns across all rows (Phase 16
|
||||
/// §5). Sums the mapped columns directly rather than <see cref="PlayCounter.TotalPlays"/>, which is an
|
||||
/// EF-ignored computed property and so not translatable. An empty counter table sums to 0 (the home
|
||||
/// card's expected reading until the telemetry migration is applied).
|
||||
/// </summary>
|
||||
public Task<long> CountTotalPlaysAsync(CancellationToken ct = default)
|
||||
=> _context.PlayCounters
|
||||
.SumAsync(c => c.PartialCount + c.SampledCount + c.CompleteCount, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Count distinct non-null anon ids across every play event (Phase 16 §3 / §4.2 — the all-time
|
||||
/// unique-listener metric, D3). Null anon ids (events where the listener sent no token, or storage
|
||||
/// was unavailable) are excluded — they are not a known listener and must not inflate the count. This
|
||||
/// is the site-wide listener reach figure; the per-track / per-release overloads scope it.
|
||||
/// </summary>
|
||||
public Task<int> CountDistinctListenersAsync(CancellationToken ct = default)
|
||||
=> _context.PlayEvents
|
||||
.Where(e => e.AnonId != null)
|
||||
.Select(e => e.AnonId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// Distinct listeners for one track, keyed by its vault entry key (the same key the play event
|
||||
/// stamps). Null anon ids excluded. Per-track scope of <see cref="CountDistinctListenersAsync()"/>.
|
||||
/// </summary>
|
||||
public Task<int> CountDistinctListenersForTrackAsync(string trackEntryKey, CancellationToken ct = default)
|
||||
=> _context.PlayEvents
|
||||
.Where(e => e.TrackEntryKey == trackEntryKey && e.AnonId != null)
|
||||
.Select(e => e.AnonId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
|
||||
/// <summary>
|
||||
/// Distinct listeners for one release, derived across the release's tracks (D4): the play event
|
||||
/// stamps the resolved release id at write time, so a distinct count over <c>anon_id</c> filtered by
|
||||
/// <c>release_id</c> is exactly "distinct listeners who played any track in this release." Null anon
|
||||
/// ids excluded. A listener who heard two tracks of the release counts once (it is a distinct count
|
||||
/// over the union, not a sum of per-track counts).
|
||||
/// </summary>
|
||||
public Task<int> CountDistinctListenersForReleaseAsync(long releaseId, CancellationToken ct = default)
|
||||
=> _context.PlayEvents
|
||||
.Where(e => e.ReleaseId == releaseId && e.AnonId != null)
|
||||
.Select(e => e.AnonId)
|
||||
.Distinct()
|
||||
.CountAsync(ct);
|
||||
|
||||
/// <summary>Append one share event. No rollup table for shares in wave 16.1 — a plain insert.</summary>
|
||||
public async Task RecordShareAsync(
|
||||
ShareTargetType targetType, string targetKey, ShareChannel channel, string? anonId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
_context.ShareEvents.Add(new ShareEvent
|
||||
{
|
||||
TargetType = targetType,
|
||||
TargetKey = targetKey,
|
||||
Channel = channel,
|
||||
AnonId = anonId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// Bump the matching bucket column on the track's counter row, creating the row on first play. The
|
||||
// row is added to the change tracker but not saved here — the caller's SaveChanges/commit persists
|
||||
// it inside the same transaction as the event append.
|
||||
//
|
||||
// Race note: two concurrent first-plays of the same track can both reach this method, find no
|
||||
// counter row, and both Add a new PlayCounter. The second SaveChanges will hit the unique index on
|
||||
// (track_id) and throw, causing the outer transaction to roll back and the event to be dropped —
|
||||
// no crash, no counter corruption. At the expected play volume this is an acceptable loss; the
|
||||
// unique index is the integrity backstop.
|
||||
private async Task BumpCounterAsync(long trackId, PlayBucket bucket, CancellationToken ct)
|
||||
{
|
||||
var counter = await _context.PlayCounters.FirstOrDefaultAsync(c => c.TrackId == trackId, ct);
|
||||
if (counter is null)
|
||||
{
|
||||
counter = new PlayCounter { TrackId = trackId };
|
||||
_context.PlayCounters.Add(counter);
|
||||
}
|
||||
|
||||
switch (bucket)
|
||||
{
|
||||
case PlayBucket.Partial: counter.PartialCount++; break;
|
||||
case PlayBucket.Sampled: counter.SampledCount++; break;
|
||||
case PlayBucket.Complete: counter.CompleteCount++; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,8 +114,8 @@
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
// ~1.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload.
|
||||
private const long MaxUploadBytes = 2_000_000_000L;
|
||||
|
||||
// Release-title addressing (Album-mode batch Edit): loads the whole release by title.
|
||||
[Parameter] public string AlbumName { get; set; } = string.Empty;
|
||||
|
||||
@@ -108,9 +108,9 @@
|
||||
</MudContainer>
|
||||
|
||||
@code {
|
||||
// 1 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
||||
// ~1.86 GB ceiling matches DeepDrftAPI's per-request limit on api/track/upload; the
|
||||
// streaming path means the limit caps the request, not in-memory buffering.
|
||||
private const long MaxUploadBytes = 1_073_741_824L;
|
||||
private const long MaxUploadBytes = 2_000_000_000L;
|
||||
|
||||
private List<BatchRowModel> _tracks = new();
|
||||
private int _selectedIndex = -1;
|
||||
|
||||
@@ -28,11 +28,11 @@ public class CmsTrackService : ICmsTrackService
|
||||
private const int DefaultIdleTimeoutSeconds = 90;
|
||||
|
||||
// Response-wait budget: once the request body is fully on the wire the server runs AudioProcessor
|
||||
// decode → vault write → SQL persist. For a several-hundred-MB WAV this can take many minutes.
|
||||
// decode → vault write → SQL persist. For a multi-GB WAV this can exceed 10 minutes.
|
||||
// The idle heartbeat goes silent after the last byte, so a separate, larger deadline governs the
|
||||
// response-wait phase so a fully-uploaded file is never killed mid-persist.
|
||||
// Operator-tunable via Upload:ResponseTimeoutSeconds.
|
||||
private const int DefaultResponseTimeoutSeconds = 600; // 10 minutes
|
||||
private const int DefaultResponseTimeoutSeconds = 1200; // 20 minutes
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<CmsTrackService> _logger;
|
||||
|
||||
@@ -8,5 +8,9 @@
|
||||
"AllowedHosts": "*",
|
||||
"ForwardedHeaders": {
|
||||
"DisableHttpsRedirection": false
|
||||
},
|
||||
"Upload": {
|
||||
"IdleTimeoutSeconds": 90,
|
||||
"ResponseTimeoutSeconds": 1200
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ Aggregate figures behind the public home hero stat row (`NowPlayingStats`). A si
|
||||
- `CutReleaseTypeCounts` (`List<CutReleaseTypeCount>`): per-`ReleaseType` Cut release counts; zero-count types are absent (suppressed server-side).
|
||||
- `MixReleaseCount` (int): total non-deleted Mix-medium releases.
|
||||
- `MixRuntimeSeconds` (double): sum of `DurationSeconds` across all non-deleted tracks on Mix releases (null durations count as 0). Rendered as `hh:mm` by `RuntimeFormat` on the client.
|
||||
- `TotalPlays` (long): site-wide total plays — sum of every `play_counter` row's `PartialCount + SampledCount + CompleteCount`, all-time (Phase 16 §5). Zero until the play-telemetry migration is applied; that is expected, not an error. The Plays card's primary odometer figure.
|
||||
- `UniqueListeners` (int): site-wide distinct anonymous listeners — distinct non-null `anon_id` values across all play events, all-time (Phase 16 §3 / D7). Zero until the migration is applied. The Plays card's secondary line ("N listeners").
|
||||
|
||||
`CutReleaseTypeCount` is a nested type (`ReleaseType`, `Count` int) defined in the same file.
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Aggregate figures behind the public home hero stat row (NowPlayingStats). A single read returns
|
||||
/// everything the three cards need so the client makes one round-trip. All counts exclude soft-deleted
|
||||
/// rows. The Plays card is a static placeholder and has no field here.
|
||||
/// everything the three cards need so the client makes one round-trip. The track-domain counts exclude
|
||||
/// soft-deleted rows; the play-domain figures (Phase 16) come from the event domain.
|
||||
/// </summary>
|
||||
public class HomeStatsDto
|
||||
{
|
||||
@@ -27,6 +27,20 @@ public class HomeStatsDto
|
||||
/// duration (not yet backfilled) contribute 0. The Mixes card's secondary figure, rendered hh:mm.
|
||||
/// </summary>
|
||||
public double MixRuntimeSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Site-wide total plays across all tracks — the sum of every play_counter's bucket columns
|
||||
/// (partial + sampled + complete), all-time (Phase 16 §5). The Plays card's primary odometer figure.
|
||||
/// Reads zero until the play-telemetry migration is applied; that is expected, not an error.
|
||||
/// </summary>
|
||||
public long TotalPlays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Site-wide distinct anonymous listeners — distinct non-null anon_id across all play events,
|
||||
/// all-time (Phase 16 §3 / D7). The Plays card's secondary line ("N listeners"). Over-counts by
|
||||
/// design (one token per browser-install, honestly labelled "listeners").
|
||||
/// </summary>
|
||||
public int UniqueListeners { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One row of the Cut release-type breakdown: a ReleaseType and how many Cut releases have it.</summary>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Wire payload for <c>POST api/event/play</c> (Phase 16 §2.2 / §4.3). The client sends only what it
|
||||
/// cheaply knows — the track key and the client-computed completion bucket; the server resolves the
|
||||
/// release. No duration or raw position is transmitted (a privacy plus — only a coarse bucket leaves
|
||||
/// the browser). <see cref="AnonId"/> is reserved for wave 16.3 and stays null in wave 16.1.
|
||||
/// </summary>
|
||||
public class PlayEventDto
|
||||
{
|
||||
public string? TrackEntryKey { get; set; }
|
||||
|
||||
public PlayBucket Bucket { get; set; }
|
||||
|
||||
public string? AnonId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftModels.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Wire payload for <c>POST api/event/share</c> (Phase 16 §2.2 / §4.3). The popover knows the target
|
||||
/// and channel at the point of the action, so the payload is self-describing — no server-side resolution.
|
||||
/// <see cref="AnonId"/> is reserved for wave 16.3 and stays null in wave 16.1.
|
||||
/// </summary>
|
||||
public class ShareEventDto
|
||||
{
|
||||
public ShareTargetType TargetType { get; set; }
|
||||
|
||||
public string? TargetKey { get; set; }
|
||||
|
||||
public ShareChannel Channel { get; set; }
|
||||
|
||||
public string? AnonId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace DeepDrftModels.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Incremental rollup of play counts per track (Phase 16 §4.1 / D6). One row per track, bumped inside
|
||||
/// the same transaction that appends the <see cref="PlayEvent"/> — no background aggregation job. The
|
||||
/// home card and per-target reads sum these instead of <c>COUNT(*)</c>-ing the event log on every
|
||||
/// landing. Release totals are <em>derived</em> (D4) by summing the counters of the release's tracks,
|
||||
/// so there is no separate release-counter row — this keeps the rollup normalized at one row per track.
|
||||
/// </summary>
|
||||
public class PlayCounter
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>The track these counts belong to (SQL id). Unique — one counter row per track.</summary>
|
||||
public long TrackId { get; set; }
|
||||
|
||||
/// <summary>Count of plays that ended in the <c>Partial</c> bucket (< 30%).</summary>
|
||||
public long PartialCount { get; set; }
|
||||
|
||||
/// <summary>Count of plays that ended in the <c>Sampled</c> bucket (30%–80%).</summary>
|
||||
public long SampledCount { get; set; }
|
||||
|
||||
/// <summary>Count of plays that ended in the <c>Complete</c> bucket (> 80%).</summary>
|
||||
public long CompleteCount { get; set; }
|
||||
|
||||
/// <summary>Total plays for the track — the sum of the three bucket counts (headline figure).</summary>
|
||||
public long TotalPlays => PartialCount + SampledCount + CompleteCount;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftModels.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only log row for one recorded play (Phase 16 §4.2). Written once at session close, after the
|
||||
/// engagement floor is crossed; never updated or deleted. Deliberately NOT a <c>BaseEntity</c>: events
|
||||
/// have no soft-delete lifecycle, no <c>UpdatedAt</c> — they are immutable facts. The release link is
|
||||
/// resolved server-side from the track key at write time (§2.3 / D4) and stored here so release-total
|
||||
/// plays are a cheap sum over this column.
|
||||
/// </summary>
|
||||
public class PlayEvent
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>The played track's vault entry key (the only target the client sends).</summary>
|
||||
public required string TrackEntryKey { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The owning release's SQL id, resolved from <see cref="TrackEntryKey"/> at write time. Null when
|
||||
/// the track is loose (no release) or the key did not resolve to a live track at write time.
|
||||
/// </summary>
|
||||
public long? ReleaseId { get; set; }
|
||||
|
||||
/// <summary>The completion bucket computed client-side from the high-water fraction (§1a / D1).</summary>
|
||||
public PlayBucket Bucket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous listener token (Phase 16 §3, wave 16.3). Reserved nullable; nothing writes it in wave
|
||||
/// 16.1 — the client sends none and the column stays NULL. Wave 16.3 lights it up for the
|
||||
/// distinct-listener count.
|
||||
/// </summary>
|
||||
public string? AnonId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftModels.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Append-only log row for one recorded share (Phase 16 §4.2). Written once per share action that
|
||||
/// survives the per-(target,channel) client debounce; never updated or deleted. Like <see cref="PlayEvent"/>
|
||||
/// it is deliberately NOT a <c>BaseEntity</c> — an immutable fact with no soft-delete lifecycle. Shares
|
||||
/// carry their target directly (the popover knows track vs. release), so no server-side resolution step.
|
||||
/// </summary>
|
||||
public class ShareEvent
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
/// <summary>Whether the share targets a track or a release.</summary>
|
||||
public ShareTargetType TargetType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The shared target's key: a track's vault <c>EntryKey</c> or a release's public <c>EntryKey</c>,
|
||||
/// selected by <see cref="TargetType"/>. Stored as the opaque key, not resolved to a SQL id — the
|
||||
/// share metric is a simple per-target tally and needs no join in wave 16.1.
|
||||
/// </summary>
|
||||
public required string TargetKey { get; set; }
|
||||
|
||||
/// <summary>The channel the share was performed through (link vs. embed).</summary>
|
||||
public ShareChannel Channel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Anonymous listener token (Phase 16 §3, wave 16.3). Reserved nullable; unused in wave 16.1.
|
||||
/// </summary>
|
||||
public string? AnonId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DeepDrftModels.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Completion bucket for a recorded play (Phase 16 §1a / D1). The three buckets are exhaustive and
|
||||
/// non-overlapping, classified by the high-water playback fraction reached before the session closed:
|
||||
/// <c>Partial</c> [0, 30%), <c>Sampled</c> [30%, 80%], <c>Complete</c> (80%, 100%]. The headline
|
||||
/// "Plays" figure is the sum of all three — every started listen that crosses the engagement floor
|
||||
/// is a play; the buckets are the texture beneath it.
|
||||
///
|
||||
/// Serialized as its string name on the wire — the converter on the type makes the
|
||||
/// client to proxy to API JSON contract string-based regardless of host serializer config.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<PlayBucket>))]
|
||||
public enum PlayBucket
|
||||
{
|
||||
/// <summary>Reached < 30% of duration — a skip or a brief partial listen (still past the floor).</summary>
|
||||
Partial,
|
||||
|
||||
/// <summary>Reached 30%–80% of duration — a real listen that was neither a skip nor a finish.</summary>
|
||||
Sampled,
|
||||
|
||||
/// <summary>Reached > 80% of duration — effectively a finished listen.</summary>
|
||||
Complete
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DeepDrftModels.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// The channel a share was performed through (Phase 16 §1b). Today both originate from
|
||||
/// <c>SharePopover</c>'s clipboard actions; a future native/Web-Share button would add a channel
|
||||
/// without reshaping the metric. Serialized as its string name on the wire.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ShareChannel>))]
|
||||
public enum ShareChannel
|
||||
{
|
||||
/// <summary>Copy-link — the canonical track or release URL placed on the clipboard.</summary>
|
||||
Link,
|
||||
|
||||
/// <summary>Copy-embed — the <c><iframe></c> snippet for the single-track FramePlayer.</summary>
|
||||
Embed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a share targets (Phase 16 §1b). Tracks and releases are both shareable; the popover knows
|
||||
/// which it is at the point of the action, so no server-side resolution is needed for shares.
|
||||
/// Serialized as its string name on the wire.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<ShareTargetType>))]
|
||||
public enum ShareTargetType
|
||||
{
|
||||
/// <summary>The share targets a single track, addressed by its vault <c>EntryKey</c>.</summary>
|
||||
Track,
|
||||
|
||||
/// <summary>The share targets a release, addressed by its public <c>EntryKey</c>.</summary>
|
||||
Release
|
||||
}
|
||||
@@ -10,7 +10,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
|
||||
## Actual structure
|
||||
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`** — `PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`** — `PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
|
||||
- `Controls/`: Reusable components.
|
||||
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
|
||||
@@ -19,7 +19,8 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
|
||||
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
|
||||
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
|
||||
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`.
|
||||
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. In embedded (`Fixed`) mode, skip-previous and skip-next render when `!Fixed || HasPrevious || HasNext` — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (`!Fixed` only).
|
||||
- `AudioPlayerBar/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
|
||||
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
|
||||
- `AudioPlayerBar/LevelMeterFab.razor`: Floating-action button replacing the static FAB in the minimized dock. Renders a continuous vertical fill inside the music-note silhouette that tracks live audio level (0–100%), with fixed three-zone gradient (green 0–60%, yellow 60–85%, orange 85–100%). Note silhouette always visible at 25% opacity; idle when paused/stopped. Reuses spectrum-callback infrastructure.
|
||||
- `SpectrumVisualizer.razor`: Bar-graph spectrum display, driven by `getSpectrumData` JS callback.
|
||||
@@ -30,12 +31,17 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `WaveformVisualizerControlPopover.razor`: Pairs the lava-lamp icon button with `WaveformVisualizerControls` as a **screen-centered tinted modal** (Phase 15). The primitive is `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) — **not** `MudPopover`; `AnchorOrigin`/`TransformOrigin` parameters do not exist (a centered modal has no anchor). Clicking the lava-lamp icon opens the overlay; clicking the scrim closes it (knob-drag-safe: `RadialKnob`'s `position:fixed` capture div sits above the scrim during a drag, so releasing outside the panel never fires the close handler). The panel stops click propagation so an inside click is not a dismissal. `[Parameter] Size IconSize` controls the trigger-icon size (default `Large`). This is the unit every host places — one icon anywhere gives the full control panel centered on screen, regardless of where the icon sits. Placed identically on Mix, Cut, Session, and the NowPlaying hero panel (full parity; in NowPlaying it sits in `.np-visualizer-controls` at the panel's top-right corner, not inside `NowPlayingCard`).
|
||||
- `WaveformZoomMapping.cs`: Maps the `WaveformVisualizerControlState.Resolution` fraction to an integer zoom level for the WebGL renderer.
|
||||
- `NowPlayingCard.razor`: Home-page text panel showing the currently playing track (label, title, sub-line). Renders label/"Now Playing" dot, track name, and artist·release sub-line from the cascaded `IStreamingPlayerService`. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose to re-render on track/state change. No visualizer or popover; those moved to `NowPlaying.razor`.
|
||||
- `NowPlayingStats.razor`: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-`ReleaseType` Cut release breakdown), Mixes (`MixReleaseCount` labelled "Sets" + `hh:mm` total mix runtime via `RuntimeFormat`), and Plays (static "XXX / Plays (Coming Soon)" odometer placeholder). Fetches `HomeStatsDto` via `IStatsDataService` on init; bridges the prerender fetch across the WASM seam with `PersistentComponentState` (persists only on a successful load, matching the medium-browse bridge pattern). Implements `IDisposable` to release the `PersistingComponentStateSubscription`.
|
||||
- `NowPlayingStats.razor`: Home hero stat row. Three cards: Studio Cuts (total Cut-medium track count + zero-suppressed per-`ReleaseType` Cut release breakdown), Mixes (`MixReleaseCount` labelled "Sets" + `hh:mm` total mix runtime via `RuntimeFormat`), and Plays (live `TotalPlays` odometer in `.hero-stat-odometer` + `UniqueListeners` "N listeners" secondary line via `.hero-stat-sub` — Phase 16 wave 16.5). All three cards read from the same `HomeStatsDto` round-trip; no extra fetch path. Fetches via `IStatsDataService` on init; bridges the prerender fetch across the WASM seam with `PersistentComponentState` (persists only on a successful load, matching the medium-browse bridge pattern). Implements `IDisposable` to release the `PersistingComponentStateSubscription`.
|
||||
- `NowPlaying.razor`: Owns the home hero's right-side panel (`.now-playing-panel` — the outer wrapper formerly called `.hero-right` in `Home.razor`). Mounts `<WaveformVisualizer Fill="true">` as a full-bleed background inside `.np-visualizer-bg`, `<WaveformVisualizerControlPopover>` in `.np-visualizer-controls` (top-right corner), the three pulsing `.circle-deco` rings, and the content layer (hosts `<NowPlayingCard>` + `<NowPlayingStats>`). `Home.razor`'s `MudItem` renders `<NowPlaying />` directly with no wrapper. Subscribes to `IPlayerService.StateChanged` in `OnParametersSet` (reference-guarded, idempotent) and unsubscribes on dispose — needed because the player cascade is `IsFixed` (the provider's own re-render does not reach `NowPlaying`), so the subscription is the only way to re-render and re-propagate `ReleaseEntryKey`/`TrackId`/`TrackEntryKey` into `<WaveformVisualizer>` when the playing track changes.
|
||||
- `QueueList.razor`: Shared presentational queue-list component (Phase 17 wave 17.1). Renders `Items` as an ordered list with the current track marked; `Editable` flag gates drag-reorder handles (drag handle icon + `MudDropContainer`/`MudDropZone` for reorder) and per-row remove controls. The remove (×) control is suppressed on the currently-playing row (`Editable && !isCurrent`) — the current track cannot be removed via the UI (wave 17.2; reorder of the current row is still permitted). When not editable, renders a plain `<div>` — the read-only state for the embed's fixed-order shared queue. Reorder, remove, and row-jump are surfaced to the parent as `EventCallback<(int FromIndex, int ToIndex)> OnReorder`, `EventCallback<int> OnRemove`, and `EventCallback<int> OnJump`; the component calls no `IQueueService` method itself (purely presentational, no data fetch, no player wiring). Both view modes (docked overlay 17.2, embedded panel 17.3) consume this single component differing only in hosting context and the `Editable` flag. Runs during prerender without JS interop (drag work is client-only and inert when no drag occurs).
|
||||
- `QueueOverlay.razor`: Screen-centered tinted modal hosting the docked-player editable queue (Phase 17 wave 17.2). Borrows the `WaveformVisualizerControlPopover` `MudOverlay` idiom (`DarkBackground="true"`, `Modal="true"`): the panel stops click propagation; scrim-click closes the overlay; drag-safe (the panel's capture div sits above the scrim during a drag so releasing outside the panel never fires the close handler). Auto-closes when a removal empties the queue. Hosts `QueueList` in `Editable="true"` mode. Opened/closed by the Queue toggle button in `PlayerTransportZone` (shown only when `!Fixed && Items.Count > 0`; `QueueMusic` glyph, active state when open).
|
||||
- `AddToQueueButton.razor`: Append-only Add-to-Queue button shared across detail-page play sites (Phase 17 wave 17.4). Two modes: track mode (calls `IQueueService.Enqueue` with a single `TrackDto`) and release mode (calls `IQueueService.EnqueueRange` with an ordered track list). Material `PlaylistAdd` glyph; tooltip "Add to queue" (track mode) / "Add release to queue" (release mode). Reads the cascaded `IQueueService`; disabled until interactive or when the cascade is absent. Append-only — does not play, does not navigate. Placed at: `CutDetail` header (release mode, `TrackNumber`-ordered list), `CutDetail` track rows (track mode), `SessionDetail` hero play (track mode), `MixDetail` hero play (track mode). Excluded from `StreamNowButton` (OQ9) and `ReleaseGallery` cards (OQ10, deferred).
|
||||
- `ReleaseDetailScaffold.razor`: Shared scaffold for release detail pages. Gained an optional `Ambient` `RenderFragment` slot (Phase 12) — a full-bleed layer rendered behind the main content. Absent slot = no regression. Cut mounts `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` here; Mix uses its own full-bleed mount outside the scaffold.
|
||||
- `SharePopover.razor`: Share affordance serving both track and release surfaces from one clipboard/popover-chrome source. **Track mode** (`EntryKey` set): copies the track's canonical URL and offers an iframe embed snippet pointing at `FramePlayer?TrackEntryKey=…`. **Release mode** (`ReleaseEntryKey` + `ReleaseMedium` set): copies the release's canonical detail URL (via `ReleaseRoutes.DetailHref`) and offers an iframe embed snippet pointing at `FramePlayer?ReleaseEntryKey=…`, which queues and auto-advances through the release's tracks on first play. Both modes offer the embed affordance — release mode no longer suppresses it. The iframe snippet is built by `EmbedSnippetBuilder`. A transient "Copied!" confirmation resets after a short delay.
|
||||
- `Helpers/`: Utilities and mapper functions.
|
||||
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
|
||||
- `RuntimeFormat.cs`: Static `ToHoursMinutes(double totalSeconds)` helper. Formats a seconds value as `h:mm` (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return `"0:00"`. Used by `NowPlayingStats` for the mix runtime figure.
|
||||
- `EmbedSnippetBuilder.cs`: Static helper that builds the iframe embed snippet the share popover copies. `ForTrack(baseUri, trackEntryKey)` → `<iframe src="…FramePlayer?TrackEntryKey=…">` and `ForRelease(baseUri, releaseEntryKey)` → `<iframe src="…FramePlayer?ReleaseEntryKey=…">`. iframe chrome (dimensions, border-radius, autoplay permission) is identical across both targets and defined once here.
|
||||
- `Services/`: Audio player + dark-mode services.
|
||||
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
|
||||
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
|
||||
@@ -43,6 +49,12 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `AudioInteropService`: JS interop wrapper over `window.DeepDrftAudio`. Manages `DotNetObjectReference` lifetimes for progress, end-of-playback, spectrum callbacks.
|
||||
- Dark-mode services: `DarkModeServiceBase` (cookie name constant), `DarkModeCookieService` (JS cookie read/write).
|
||||
- `WaveformVisualizerControlState`: Scoped session-persistent holder for the visualizer's **eight** continuous control positions plus **two subsystem on/off toggles** (Phase 15): `ScrollSpeed`, `GradientRotationSpeed`, `LavaGravity`, `LavaHeat`, `FluidAmount` (wax count/volume), `FluidViscosity` (cohesion — the second half of the Phase 10 "bubbles" split; `BlobDensity` is gone), `CollisionStrength`, `WaveformWidth`, `LavaEnabled` (bool, default `true`), `WaveformEnabled` (bool, default `true`). Each has a matching `Default*` const. `Changed` event is the decoupling seam — controls mutate state + raise `Changed`; the bridge (`WaveformVisualizer`) subscribes and pushes the affected uniform or subsystem-enable. Scoped DI so state survives SPA nav within a session and resets on fresh page load.
|
||||
- `PlayTracker`: Per-session play-session tracker (Phase 16 wave 16.1). Opens on playback start, advances a high-water position on each progress tick (from `StreamingAudioPlayerService` — not the HTTP layer, so seek-beyond-buffer re-fetches are the same play), closes on track-switch / stop / organic-end / page-unload. Engagement floor: ≥3 s OR ≥5% of duration. Three-bucket classification (`partial`/`sampled`/`complete`). Emits at most one event per session via `IPlayEventSink`. No player or JS dependency — testable against a fake sink.
|
||||
- `ShareTracker`: Per-session share tracker (Phase 16 wave 16.1). Called by `SharePopover` after a successful clipboard write; applies a 60-second per-(target, channel) debounce. Sends via `BeaconInterop`. Scoped so debounce memory resets on fresh page load. **Wave 16.3:** injects `IAnonIdProvider`; attaches `_anonId.Current` to `ShareEventDto.AnonId` (omitted when null).
|
||||
- `BeaconInterop`: `navigator.sendBeacon` JS interop wrapper (Phase 16 wave 16.1). Fires JSON payloads to `api/event/{play,share}` fire-and-forget. Also wires a page-unload handler that flushes any pending play event when the page is torn down.
|
||||
- `BeaconPlayEventSink`: Production `IPlayEventSink` (Phase 16 wave 16.1). Serializes the play classification and fires it via `BeaconInterop` to `api/event/play`. Synchronous (`EmitPlay` cannot await — it is called from the player close path and the page-unload handler). **Wave 16.3:** injects `IAnonIdProvider`; reads `_anonId.Current` synchronously at emit time and sets `PlayEventDto.AnonId` (omitted when null via `WhenWritingNull`).
|
||||
- `IAnonIdProvider` / `AnonIdProvider`: Wave 16.3 anonymous-listener id seam. `IAnonIdProvider` exposes `string? Current` (synchronous cached read, safe on the unload path) and `ValueTask EnsureLoadedAsync()` (warms the cache from `localStorage` via `window.DeepDrftAnonId.get` JS interop — idempotent, never throws). `AnonIdProvider` is the production implementation; degrades to null when `localStorage` is unavailable (private mode / blocked storage). The token itself outlives the session in `localStorage`; the in-process cache is scoped (resets on fresh page load). Callers warm the cache when going interactive, then read `Current` synchronously on the close/unload path with no extra JS hop. TypeScript interop: `DeepDrftPublic/Interop/telemetry/anonid.ts` (mints GUID on first visit, returns null without throwing when storage is unavailable).
|
||||
- `IQueueService` / `QueueService`: Ordered playback orchestrator above the single-slot player. `PlayRelease(tracks, startIndex)` replaces the queue and starts streaming; `Next`/`Previous` advance or step back; `Enqueue`/`EnqueueRange` append without interrupting the current track; `Clear` empties the queue. **Armed-idle state** added to support prerender-safe release embeds: `Arm(tracks)` loads the track list at index 0 with no JS interop (safe during prerender); `IsArmed` signals the armed-but-not-streaming state; `Start()` begins streaming the current track and clears `IsArmed`, leaving the list and position intact so auto-advance carries on. `AudioPlayerBar` reads `IsArmed` to route the first play gesture through `Start()` instead of streaming the staged track alone. `QueueChanged` event fires on all list/position changes; cascaded via `AudioPlayerProvider`. **Wave 17.1 additions:** `Move(int fromIndex, int toIndex)` reorders `Items` in-place, adjusting `CurrentIndex` so the same track stays current across the move — never re-streams or interrupts playback; `RemoveAt(int index)` removes an item and adjusts `CurrentIndex` (removing the current track does not stop playback; removing the last remaining item leaves the queue empty and dormant). Both are interop-free state mutations that re-emit `QueueChanged`. **Dormant-`Enqueue` coherence (OQ8):** `Enqueue`/`EnqueueRange` into an empty/dormant queue (`CurrentIndex == -1`) set `CurrentIndex` to 0 so a subsequent play/skip is correct — but do not auto-play. **Wave 17.2 additions:** `ClearUpcoming()` removes all queued items except the currently-playing one, leaving it as the sole item at `CurrentIndex == 0` and re-emitting `QueueChanged` — touches no playback (OQ5: Clear does not stop or remove the current track). `PlayRelease` now always materializes a defensive copy of its input (`tracks.ToList()`) so it can never alias the service's own `Items` list — fixes a row-jump bug where `PlayRelease(Items, index)` could mutate the live list mid-operation.
|
||||
- `Clients/`: HTTP API clients (both target DeepDrftAPI).
|
||||
- `TrackClient`: SQL metadata API. Uses named `IHttpClientFactory` client `"DeepDrft.API"`. Sends `page` param (not `pageNumber`). Deserializes response as bare `PagedResult<TrackDto>` (not wrapped in ApiResultDto envelope).
|
||||
- `TrackMediaClient`: Content API. Uses named `IHttpClientFactory` client `"DeepDrft.Content"`. Methods like `GetAudioStreamAsync(trackId, byteOffset?)` → `Stream` with optional Range header support for seek-beyond-buffer.
|
||||
@@ -52,6 +64,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `ViewModels/`: Component state.
|
||||
- `TracksViewModel`: Scoped. Holds current page, page size, sort column, descending flag. `SetPage(pageNumber)` calls `TrackClient.GetPageAsync` and updates. Registered in `Startup.ConfigureDomainServices`.
|
||||
- `TrackDetailViewModel`: Scoped. Holds loaded track, loading flag, not-found flag. `Load(entryKey)` fetches via `ITrackDataService` and resets all flags per call (prevents cross-navigation bleed). Registered in `Startup.ConfigureDomainServices`.
|
||||
- `FramePlayerViewModel`: Scoped. Resolves the ordered track list for a release embed (`FramePlayer?ReleaseEntryKey=…`). `Load(releaseEntryKey)` calls `IReleaseDataService.GetByEntryKey` → `release.Id` → `ITrackDataService.GetPage(sortColumn:"TrackNumber", releaseId:…)`, mirroring `CutDetailViewModel.Load` exactly so an embedded release queues the same ordered list the Cut detail page plays. Owns no playback or staging — `FramePlayer.razor` uses the loaded `Tracks` to stage and arm. Registered scoped in `Startup.ConfigureDomainServices`.
|
||||
- `Common/`: Shared utilities.
|
||||
- `DarkModeSettings.cs`: `[PersistentState]`-annotated class (single source of truth for dark mode in the client). Registered scoped.
|
||||
- `ReleaseRoutes.cs`: Static helper. `DetailHref(long id, ReleaseMedium)` returns the canonical public detail route for a release; consumed by Archive, AlbumsView, player bar, and TrackRedirect (11.B).
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftModels.DTOs
|
||||
@using DeepDrftPublic.Client.Services
|
||||
|
||||
@* Append-only "Add to Queue" affordance placed beside a play control. Add is NOT play: it calls the
|
||||
cascaded IQueueService's Enqueue/EnqueueRange (which append without disturbing current playback and
|
||||
leave a coherent CurrentIndex on a first add into a dormant queue) — never PlayRelease/Start/Select.
|
||||
Track mode (Track set) appends a single track; release mode (ReleaseTracks set) appends the whole
|
||||
ordered list. Reads queue state from the layout-level cascade (C1); owns no data fetch. *@
|
||||
|
||||
<MudTooltip Text="@Tooltip">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.PlaylistAdd"
|
||||
Color="@Color"
|
||||
Size="@Size"
|
||||
Disabled="@(Queue is null || !RendererInfo.IsInteractive)"
|
||||
OnClick="@AddToQueue" />
|
||||
</MudTooltip>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
/// <summary>Single track to append (track mode). Mutually exclusive with <see cref="ReleaseTracks"/>.</summary>
|
||||
[Parameter] public TrackDto? Track { get; set; }
|
||||
|
||||
/// <summary>Ordered release tracks to append (release mode). Mutually exclusive with <see cref="Track"/>.</summary>
|
||||
[Parameter] public IReadOnlyList<TrackDto>? ReleaseTracks { get; set; }
|
||||
|
||||
[Parameter] public Size Size { get; set; } = Size.Medium;
|
||||
[Parameter] public Color Color { get; set; } = Color.Secondary;
|
||||
|
||||
private string Tooltip => ReleaseTracks is not null ? "Add release to queue" : "Add to queue";
|
||||
|
||||
private void AddToQueue()
|
||||
{
|
||||
if (Queue is null) return;
|
||||
|
||||
if (ReleaseTracks is not null)
|
||||
{
|
||||
Queue.EnqueueRange(ReleaseTracks);
|
||||
}
|
||||
else if (Track is not null)
|
||||
{
|
||||
Queue.Enqueue(Track);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,15 @@ else
|
||||
HasPrevious="HasPrevious"
|
||||
SkipNext="@SkipNext"
|
||||
SkipPrevious="@SkipPrevious"
|
||||
ShowQueueButton="ShowQueueButton"
|
||||
QueueOpen="_queueOpen"
|
||||
QueueToggle="@ToggleQueue"
|
||||
Class="transport-zone"/>
|
||||
|
||||
<VolumeZone Volume="@Volume" VolumeChanged="@OnVolumeChange"/>
|
||||
|
||||
<div class="meta-zone">
|
||||
<TrackMetaLabel Track="CurrentTrack"/>
|
||||
<TrackMetaLabel Track="CurrentTrack" Fixed="Fixed"/>
|
||||
</div>
|
||||
|
||||
<PlayerSeekZone OnSeekStart="@OnSeekStart"
|
||||
@@ -49,12 +52,27 @@ else
|
||||
|
||||
@if (!string.IsNullOrEmpty(ErrorMessage))
|
||||
{
|
||||
<MudAlert Severity="Severity.Error"
|
||||
ShowCloseIcon="true"
|
||||
CloseIconClicked="ClearError"
|
||||
<MudAlert Severity="Severity.Error"
|
||||
ShowCloseIcon="true"
|
||||
CloseIconClicked="ClearError"
|
||||
Class="ma-2">
|
||||
@ErrorMessage
|
||||
</MudAlert>
|
||||
}
|
||||
|
||||
@* Docked queue overlay (Phase 17 §3.2). MudOverlay portals to the body, so its position here in
|
||||
the dock subtree does not affect its screen-centered rendering. Only mounted in docked mode —
|
||||
the Fixed embed gets its own inline panel in a later wave. *@
|
||||
@if (ShowQueueButton)
|
||||
{
|
||||
<QueueOverlay Visible="_queueOpen"
|
||||
Items="QueueItems"
|
||||
CurrentIndex="QueueCurrentIndex"
|
||||
OnClose="@CloseQueue"
|
||||
OnClear="@ClearUpcoming"
|
||||
OnReorder="@OnQueueReorder"
|
||||
OnRemove="@OnQueueRemove"
|
||||
OnJump="@OnQueueJump"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private bool _isMinimized = true;
|
||||
private bool _isSeeking = false;
|
||||
private double _seekPosition = 0;
|
||||
private bool _queueOpen = false;
|
||||
private IStreamingPlayerService? _subscribedService;
|
||||
private IQueueService? _subscribedQueue;
|
||||
|
||||
@@ -63,6 +64,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private bool HasNext => QueueService?.HasNext ?? false;
|
||||
private bool HasPrevious => QueueService?.HasPrevious ?? false;
|
||||
|
||||
// Queue overlay state. The button (and overlay) appear only in docked mode with a non-empty queue,
|
||||
// mirroring the skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue
|
||||
// self. The Fixed embed gets an inline panel in a later wave, so the docked overlay is !Fixed-only.
|
||||
private bool ShowQueueButton => !Fixed && (QueueService?.Items.Count ?? 0) > 0;
|
||||
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
|
||||
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
|
||||
|
||||
/// <summary>
|
||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||
/// </summary>
|
||||
@@ -106,7 +114,16 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
|
||||
private void OnPlayerStateChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnQueueChanged() => InvokeAsync(StateHasChanged);
|
||||
private void OnQueueChanged()
|
||||
{
|
||||
// If a removal emptied the queue while the overlay was open, the button disappears (AC1) — close
|
||||
// the overlay so it cannot strand open over an empty queue. The button gate hides the overlay
|
||||
// mount too, so this keeps state and view consistent.
|
||||
if (_queueOpen && (QueueService?.Items.Count ?? 0) == 0)
|
||||
_queueOpen = false;
|
||||
|
||||
InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task SkipNext()
|
||||
{
|
||||
@@ -120,6 +137,27 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
await QueueService.Previous();
|
||||
}
|
||||
|
||||
private void ToggleQueue() => _queueOpen = !_queueOpen;
|
||||
|
||||
private void CloseQueue() => _queueOpen = false;
|
||||
|
||||
// Reorder/remove/clear are interop-free engine mutations (C2/C5): they never re-stream or interrupt
|
||||
// the playing track. QueueChanged re-renders the bar and the overlay's list.
|
||||
private void OnQueueReorder((int FromIndex, int ToIndex) move) =>
|
||||
QueueService?.Move(move.FromIndex, move.ToIndex);
|
||||
|
||||
private void OnQueueRemove(int index) => QueueService?.RemoveAt(index);
|
||||
|
||||
private void ClearUpcoming() => QueueService?.ClearUpcoming();
|
||||
|
||||
// Jump reuses the existing "play from index" semantics (OQ2). This is the one queue action that
|
||||
// touches playback — it streams the chosen track via the player.
|
||||
private async Task OnQueueJump(int index)
|
||||
{
|
||||
if (QueueService == null) return;
|
||||
await QueueService.PlayRelease(QueueService.Items, index);
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
// The Fixed embed is already in normal flow — no spacer/clip needed.
|
||||
@@ -186,6 +224,16 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
// the first play click — the user gesture the browser requires before audio can start.
|
||||
if (IsStaged)
|
||||
{
|
||||
// Release embed: the queue is armed with the whole release. Route the first gesture through
|
||||
// the queue so it takes over (streams track 0 and auto-advances) rather than streaming the
|
||||
// staged track in isolation. Single-track embeds leave the queue disarmed and fall through
|
||||
// to the direct stream below — unchanged.
|
||||
if (QueueService is { IsArmed: true })
|
||||
{
|
||||
await QueueService.Start();
|
||||
return;
|
||||
}
|
||||
|
||||
await PlayerService.SelectTrackStreaming(PlayerService.CurrentTrack!);
|
||||
return;
|
||||
}
|
||||
@@ -250,6 +298,12 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
_subscribedService = null;
|
||||
}
|
||||
|
||||
if (_subscribedQueue != null)
|
||||
{
|
||||
_subscribedQueue.QueueChanged -= OnQueueChanged;
|
||||
_subscribedQueue = null;
|
||||
}
|
||||
|
||||
if (_spacerModule is not null)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
@using DeepDrftPublic.Client.Controls
|
||||
|
||||
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
|
||||
@if (!Fixed)
|
||||
@if (!Fixed || HasPrevious || HasNext)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
|
||||
Color="Color.Primary"
|
||||
@@ -14,13 +14,16 @@
|
||||
Color="Color.Primary"
|
||||
Disabled="!CanPlay"
|
||||
OnToggle="@TogglePlayPause"/>
|
||||
@if (!Fixed)
|
||||
@if (!Fixed || HasPrevious || HasNext)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipNext"
|
||||
Disabled="!HasNext"/>
|
||||
}
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
|
||||
@@ -20,5 +20,20 @@
|
||||
Indeterminate="@(LoadProgress == 0)"/>
|
||||
}
|
||||
</MudStack>
|
||||
@* Queue toggle: a second row between the transport controls and the timestamp (§3.1 placement —
|
||||
"below the control buttons, to the left of the timestamps"). Shown only when a queue is loaded,
|
||||
mirroring the skip-affordance gating, so an empty/single-track player is byte-for-byte unchanged. *@
|
||||
@if (ShowQueueButton)
|
||||
{
|
||||
<MudTooltip Text="Queue">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Medium"
|
||||
OnClick="QueueToggle"
|
||||
aria-label="Queue"
|
||||
aria-expanded="@QueueOpen"
|
||||
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
||||
</MudStack>
|
||||
|
||||
@@ -18,5 +18,15 @@ public partial class PlayerTransportZone : ComponentBase
|
||||
[Parameter] public bool HasPrevious { get; set; }
|
||||
[Parameter] public EventCallback SkipNext { get; set; }
|
||||
[Parameter] public EventCallback SkipPrevious { get; set; }
|
||||
|
||||
/// <summary>Whether to render the Queue toggle button. Gated on a non-empty queue by the bar.</summary>
|
||||
[Parameter] public bool ShowQueueButton { get; set; }
|
||||
|
||||
/// <summary>Whether the queue overlay is open. Drives the button's active state.</summary>
|
||||
[Parameter] public bool QueueOpen { get; set; }
|
||||
|
||||
/// <summary>Raised when the Queue button is clicked. The bar toggles the overlay.</summary>
|
||||
[Parameter] public EventCallback QueueToggle { get; set; }
|
||||
|
||||
[Parameter] public string? Class { get; set; }
|
||||
}
|
||||
|
||||
@@ -8,14 +8,26 @@
|
||||
<div class="track-meta-identity">
|
||||
@* Title links to the release's dedicated detail page via the shared resolver (§2): the
|
||||
TrackDto already carries Release { Id, Medium }, so no round-trip is needed. When no
|
||||
release is attached there is no medium to resolve, so the title renders unlinked. *@
|
||||
release is attached there is no medium to resolve, so the title renders unlinked.
|
||||
When Fixed (embedded iframe), the link opens in a new tab so the iframe keeps playing. *@
|
||||
@if (Track.Release is not null)
|
||||
{
|
||||
<a href="@ReleaseRoutes.DetailHref(Track.Release)" style="text-decoration: none;">
|
||||
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
||||
@Track.TrackName
|
||||
</MudText>
|
||||
</a>
|
||||
@if (Fixed)
|
||||
{
|
||||
<a href="@ReleaseRoutes.DetailHref(Track.Release)" target="_blank" rel="noopener noreferrer" style="text-decoration: none;">
|
||||
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
||||
@Track.TrackName
|
||||
</MudText>
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="@ReleaseRoutes.DetailHref(Track.Release)" style="text-decoration: none;">
|
||||
<MudText Typo="Typo.subtitle2" Class="track-meta-title text-truncate">
|
||||
@Track.TrackName
|
||||
</MudText>
|
||||
</a>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -11,4 +11,5 @@ namespace DeepDrftPublic.Client.Controls.AudioPlayerBar;
|
||||
public partial class TrackMetaLabel : ComponentBase
|
||||
{
|
||||
[Parameter] public TrackDto? Track { get; set; }
|
||||
[Parameter] public bool Fixed { get; set; }
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
[Inject] public required AudioInteropService AudioInterop { get; set; }
|
||||
[Inject] public required TrackMediaClient TrackMediaClient { get; set; }
|
||||
[Inject] public required ILogger<StreamingAudioPlayerService> Logger { get; set; }
|
||||
[Inject] public required BeaconInterop Beacon { get; set; }
|
||||
[Inject] public required IPlayEventSink PlayEventSink { get; set; }
|
||||
[Inject] public required IAnonIdProvider AnonId { get; set; }
|
||||
|
||||
private IStreamingPlayerService? _audioPlayerService;
|
||||
private QueueService? _queueService;
|
||||
@@ -23,7 +26,16 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
// EnsureInitializedAsync — that path is correct because audio contexts
|
||||
// require a user gesture anyway. Initializing eagerly here causes 4+
|
||||
// SignalR round-trips before any content is stable.
|
||||
_audioPlayerService = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
|
||||
var player = new StreamingAudioPlayerService(AudioInterop, TrackMediaClient, Logger);
|
||||
|
||||
// Phase 16: bind the play-session tracker to the player after construction, the same way the
|
||||
// queue binds — the player is built with `new`, not DI, so threading telemetry through its
|
||||
// constructor would force the provider to over-resolve. The tracker owns the floor/bucket logic
|
||||
// and emits via the injected sink (the beacon in production); the beacon also drives the
|
||||
// page-unload close so a mid-play tab-close still records the listen. Attached on the concrete
|
||||
// type before it is exposed through the IStreamingPlayerService field.
|
||||
player.AttachTracker(new PlayTracker(PlayEventSink), Beacon);
|
||||
_audioPlayerService = player;
|
||||
|
||||
// Provider is the SOLE owner of OnStateChanged. When the service fires,
|
||||
// the provider re-renders, which cascades to its children automatically.
|
||||
@@ -39,6 +51,20 @@ public partial class AudioPlayerProvider : ComponentBase, IAsyncDisposable
|
||||
_queueService.Attach(_audioPlayerService);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warm the anon-id cache once the provider is interactive (Phase 16 wave 16.3). Done here, after the
|
||||
/// first render, because the localStorage read is JS interop — not available during prerender. By the
|
||||
/// time any play session closes and the sink reads <c>AnonId.Current</c>, the cache is populated; a
|
||||
/// play that somehow closes before this completes simply sends no anonId (acceptable over-count). The
|
||||
/// provider is the natural warm point: it is mounted in MainLayout, so it goes interactive on every
|
||||
/// page the player can play from.
|
||||
/// </summary>
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
await AnonId.EnsureLoadedAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispose the player on unmount so the JS setInterval driving progress
|
||||
/// callbacks no longer holds a DotNetObjectReference into a destroyed
|
||||
|
||||
@@ -29,11 +29,14 @@
|
||||
<div class="hero-stat-sub">@RuntimeFormat.ToHoursMinutes(_stats.MixRuntimeSeconds) runtime</div>
|
||||
</div>
|
||||
|
||||
@* Plays — static placeholder (real play/share tracking is a future phase). Odometer treatment over
|
||||
the existing card style; copy is placeholder pending sign-off. *@
|
||||
@* Plays — live site-wide play total in the odometer (the "90s visitor counter" aesthetic is the
|
||||
intended treatment). Secondary line is unique anonymous listeners (Phase 16 D7). Both read from
|
||||
the same HomeStatsDto round-trip the other two cards use — no extra fetch. Reads zero until the
|
||||
play-telemetry migration is applied. *@
|
||||
<div class="hero-stat">
|
||||
<div class="hero-stat-num hero-stat-odometer">XXX</div>
|
||||
<div class="hero-stat-label">Plays (Coming Soon)</div>
|
||||
<div class="hero-stat-num hero-stat-odometer">@_stats.TotalPlays</div>
|
||||
<div class="hero-stat-label">Plays</div>
|
||||
<div class="hero-stat-sub">@_stats.UniqueListeners listeners</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftModels.DTOs
|
||||
|
||||
@* Shared presentational queue list. Renders the ordered queue with the current track marked, and
|
||||
(when Editable) drag-reorder handles + per-row remove controls. This is the single "view" both
|
||||
the docked overlay (17.2) and the embedded panel (17.3) consume — one source, multiple views.
|
||||
|
||||
Purely presentational: owns no data fetch, no player wiring, and no IQueueService mutation of its
|
||||
own. Order changes, removals, and row jumps are surfaced to the parent as EventCallbacks; the
|
||||
parent calls the queue engine. It runs during prerender without JS interop (MudDropContainer's
|
||||
drag work is client-only and inert when no drag occurs). *@
|
||||
|
||||
@if (Items is { Count: > 0 })
|
||||
{
|
||||
@if (Editable)
|
||||
{
|
||||
<MudDropContainer T="QueueRow" @ref="_dropContainer" Items="Rows" ItemsSelector="@((row, zone) => true)"
|
||||
ItemDropped="OnItemDropped" Class="deepdrft-queue-list">
|
||||
<ChildContent>
|
||||
<MudDropZone T="QueueRow" Identifier="queue" Class="deepdrft-queue-zone" AllowReorder="true"/>
|
||||
</ChildContent>
|
||||
<ItemRenderer>
|
||||
@RenderRow(context)
|
||||
</ItemRenderer>
|
||||
</MudDropContainer>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="deepdrft-queue-list">
|
||||
@foreach (var row in Rows)
|
||||
{
|
||||
@RenderRow(row)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
/// <summary>The ordered tracks to render. Empty/null renders nothing.</summary>
|
||||
[Parameter] public IReadOnlyList<TrackDto>? Items { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Index of the current track within <see cref="Items"/>, or -1 when none. The matching row is
|
||||
/// rendered with a now-playing marker.
|
||||
/// </summary>
|
||||
[Parameter] public int CurrentIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>
|
||||
/// When true, rows show drag handles and a remove control and reorder is enabled. When false the
|
||||
/// list is a read-only display (the embed's fixed-order shared queue).
|
||||
/// </summary>
|
||||
[Parameter] public bool Editable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user reorders a row: <c>(fromIndex, toIndex)</c>. The parent calls
|
||||
/// <c>IQueueService.Move</c>. Only fires when <see cref="Editable"/>.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<(int FromIndex, int ToIndex)> OnReorder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user removes a row, carrying the row's index. The parent calls
|
||||
/// <c>IQueueService.RemoveAt</c>. Only fires when <see cref="Editable"/>.
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<int> OnRemove { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Raised when the user clicks a row body to jump playback to it, carrying the row's index. The
|
||||
/// parent decides whether/how to honour it (e.g. play from that index).
|
||||
/// </summary>
|
||||
[Parameter] public EventCallback<int> OnJump { get; set; }
|
||||
|
||||
private MudDropContainer<QueueRow>? _dropContainer;
|
||||
|
||||
// Index-tagged view rows. The index is the row's position in Items at render time and is the
|
||||
// value surfaced to the parent's callbacks — the component never mutates the underlying list.
|
||||
private List<QueueRow> Rows =>
|
||||
Items is null
|
||||
? []
|
||||
: Items.Select((track, index) => new QueueRow(index, track)).ToList();
|
||||
|
||||
private async Task OnItemDropped(MudItemDropInfo<QueueRow> dropInfo)
|
||||
{
|
||||
var from = dropInfo.Item!.Index;
|
||||
var to = dropInfo.IndexInZone;
|
||||
// MudDropContainer recomputes the list from the parent's next render; refresh its snapshot so
|
||||
// the dragged row snaps back until the parent's Move re-flows the cascaded Items.
|
||||
_dropContainer?.Refresh();
|
||||
if (from == to) return;
|
||||
await OnReorder.InvokeAsync((from, to));
|
||||
}
|
||||
|
||||
private sealed record QueueRow(int Index, TrackDto Track);
|
||||
|
||||
private RenderFragment RenderRow(QueueRow row) => __builder =>
|
||||
{
|
||||
var isCurrent = row.Index == CurrentIndex;
|
||||
<div class="@($"deepdrft-queue-row{(isCurrent ? " deepdrft-queue-row-current" : "")}")">
|
||||
@if (Editable)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.DragIndicator" Size="Size.Small"
|
||||
Class="deepdrft-queue-drag-handle"/>
|
||||
}
|
||||
<span class="deepdrft-queue-position">@(row.Index + 1)</span>
|
||||
<div class="deepdrft-queue-body" @onclick="() => OnJump.InvokeAsync(row.Index)">
|
||||
<span class="deepdrft-queue-title">@row.Track.TrackName</span>
|
||||
@if (row.Track.Release is { Artist: var artist } && !string.IsNullOrWhiteSpace(artist))
|
||||
{
|
||||
<span class="deepdrft-queue-artist">@artist</span>
|
||||
}
|
||||
</div>
|
||||
@if (isCurrent)
|
||||
{
|
||||
<MudIcon Icon="@Icons.Material.Filled.GraphicEq" Size="Size.Small"
|
||||
Color="Color.Primary" Class="deepdrft-queue-nowplaying"/>
|
||||
}
|
||||
@* The current track cannot be removed (OQ3/OQ11): the queue empties only organically as the
|
||||
current ends with nothing after it. Suppress the × on the current row only — reorder of the
|
||||
current track is still allowed. *@
|
||||
@if (Editable && !isCurrent)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close" Size="Size.Small"
|
||||
Class="deepdrft-queue-remove" aria-label="Remove from queue"
|
||||
OnClick="() => OnRemove.InvokeAsync(row.Index)"/>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
@namespace DeepDrftPublic.Client.Controls
|
||||
@using DeepDrftModels.DTOs
|
||||
|
||||
@* The docked player's queue panel: a screen-centered, mostly-square modal hosting the editable
|
||||
QueueList (Phase 17 §3.2). The overlay shell, dismissal, and drag-safety are a direct lift of
|
||||
WaveformVisualizerControlPopover (Phase 15 §4):
|
||||
- MudOverlay (DarkBackground = mild tint, Modal = focus/scroll stay on the panel).
|
||||
- Scrim OnClick closes; the panel stops click propagation so an inside click is not a dismissal.
|
||||
- AutoClose left OFF; dismissal is the explicit scrim click only. A MudDropContainer drag that
|
||||
ends outside the panel does not synthesise a click on the scrim, so a reorder drag never
|
||||
dismisses (same drag-safety posture as the visualizer popover).
|
||||
This host owns NO queue state and NO JS interop — it renders Items/CurrentIndex and forwards
|
||||
QueueList's reorder/remove/jump callbacks plus a Clear action to the parent (AudioPlayerBar), which
|
||||
holds the cascaded IQueueService. Purely presentational; prerender-safe. *@
|
||||
|
||||
<MudOverlay Visible="@Visible"
|
||||
DarkBackground="true"
|
||||
Modal="true"
|
||||
OnClick="@OnClose"
|
||||
Class="deepdrft-queue-overlay">
|
||||
<div class="deepdrft-queue-modal" @onclick:stopPropagation="true">
|
||||
<div class="deepdrft-queue-modal-header">
|
||||
<span class="deepdrft-queue-modal-title">Up Next</span>
|
||||
<MudButton Variant="Variant.Text"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Disabled="@(!CanClear)"
|
||||
OnClick="@OnClear"
|
||||
Class="deepdrft-queue-clear">Clear</MudButton>
|
||||
</div>
|
||||
<div class="deepdrft-queue-modal-body">
|
||||
<QueueList Items="Items"
|
||||
CurrentIndex="CurrentIndex"
|
||||
Editable="true"
|
||||
OnReorder="OnReorder"
|
||||
OnRemove="OnRemove"
|
||||
OnJump="OnJump"/>
|
||||
</div>
|
||||
</div>
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
/// <summary>Whether the overlay is shown. Owned by the parent (the Queue button toggles it).</summary>
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
|
||||
/// <summary>The queue to render. Passed straight through to <see cref="QueueList"/>.</summary>
|
||||
[Parameter] public IReadOnlyList<TrackDto>? Items { get; set; }
|
||||
|
||||
/// <summary>Index of the current track within <see cref="Items"/>, or -1 when none.</summary>
|
||||
[Parameter] public int CurrentIndex { get; set; } = -1;
|
||||
|
||||
/// <summary>Raised when the scrim is clicked to dismiss the overlay.</summary>
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
/// <summary>Raised when Clear is pressed — empties the up-next, keeping the current track playing.</summary>
|
||||
[Parameter] public EventCallback OnClear { get; set; }
|
||||
|
||||
/// <summary>Reorder callback forwarded from the hosted <see cref="QueueList"/>.</summary>
|
||||
[Parameter] public EventCallback<(int FromIndex, int ToIndex)> OnReorder { get; set; }
|
||||
|
||||
/// <summary>Remove callback forwarded from the hosted <see cref="QueueList"/>.</summary>
|
||||
[Parameter] public EventCallback<int> OnRemove { get; set; }
|
||||
|
||||
/// <summary>Jump-to-track callback forwarded from the hosted <see cref="QueueList"/>.</summary>
|
||||
[Parameter] public EventCallback<int> OnJump { get; set; }
|
||||
|
||||
// Clear is meaningful only when there is something beyond the current track to discard.
|
||||
private bool CanClear => Items is { Count: > 1 };
|
||||
}
|
||||
@@ -42,35 +42,33 @@
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@* Embed is a single-track affordance only; a release page is not a single-track embed (§3b.3). *@
|
||||
@if (!IsReleaseMode)
|
||||
@* Embed is offered in both modes: a track snippet (TrackEntryKey) or a whole-release
|
||||
snippet (ReleaseEntryKey) targeting FramePlayer's matching query param. *@
|
||||
<MudDivider />
|
||||
|
||||
<MudCheckBox @bind-Value="Embed" Color="Color.Primary" Label="Embed player" Dense="true" />
|
||||
|
||||
@if (_embed)
|
||||
{
|
||||
<MudDivider />
|
||||
|
||||
<MudCheckBox @bind-Value="Embed" Color="Color.Primary" Label="Embed player" Dense="true" />
|
||||
|
||||
@if (_embed)
|
||||
{
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudTextField Value="@EmbedSnippet"
|
||||
T="string"
|
||||
ReadOnly="true"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3"
|
||||
Margin="Margin.Dense"
|
||||
Class="deepdrft-share-embed-field" />
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="0">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
Color="Color.Primary"
|
||||
OnClick="@CopyEmbed"
|
||||
aria-label="Copy embed snippet" />
|
||||
@if (_embedCopied)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
|
||||
<MudTextField Value="@EmbedSnippet"
|
||||
T="string"
|
||||
ReadOnly="true"
|
||||
Variant="Variant.Outlined"
|
||||
Lines="3"
|
||||
Margin="Margin.Dense"
|
||||
Class="deepdrft-share-embed-field" />
|
||||
<MudStack AlignItems="AlignItems.Center" Spacing="0">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.ContentCopy"
|
||||
Color="Color.Primary"
|
||||
OnClick="@CopyEmbed"
|
||||
aria-label="Copy embed snippet" />
|
||||
@if (_embedCopied)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Success">Copied!</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
|
||||
</MudStack>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Common;
|
||||
using DeepDrftPublic.Client.Helpers;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
@@ -7,19 +9,19 @@ namespace DeepDrftPublic.Client.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// Share affordance with two modes from one source of clipboard/popover-chrome logic
|
||||
/// (Phase 11 §3b). Track mode (<see cref="EntryKey"/> set) offers a canonical-link copy plus an
|
||||
/// optional iframe embed snippet. Release mode (<see cref="ReleaseEntryKey"/> set) is copy-link-only —
|
||||
/// it copies the absolute form of the release's canonical detail URL and hides the embed
|
||||
/// affordance, since a release page is not a single-track embed. Clipboard writes go through
|
||||
/// navigator.clipboard; each copy shows a transient "Copied!" confirmation that resets after a
|
||||
/// short delay.
|
||||
/// (Phase 11 §3b). Both modes offer a canonical-link copy plus an optional iframe embed snippet.
|
||||
/// Track mode (<see cref="EntryKey"/> set) embeds a single track (FramePlayer?TrackEntryKey=...);
|
||||
/// release mode (<see cref="ReleaseEntryKey"/> set) copies the release's canonical detail URL and
|
||||
/// embeds the whole release (FramePlayer?ReleaseEntryKey=...), which queues and advances through its
|
||||
/// tracks on first play. Clipboard writes go through navigator.clipboard; each copy shows a transient
|
||||
/// "Copied!" confirmation that resets after a short delay.
|
||||
/// </summary>
|
||||
public partial class SharePopover : ComponentBase, IDisposable
|
||||
{
|
||||
/// <summary>Track mode: the vault entry key of the track to share. Mutually exclusive with the release target.</summary>
|
||||
[Parameter] public string? EntryKey { get; set; }
|
||||
|
||||
/// <summary>Release mode: the release's opaque public EntryKey to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and omits the embed option.</summary>
|
||||
/// <summary>Release mode: the release's opaque public EntryKey to share. When set (with <see cref="ReleaseMedium"/>), the popover shares the release detail URL and embeds the whole release.</summary>
|
||||
[Parameter] public string? ReleaseEntryKey { get; set; }
|
||||
|
||||
/// <summary>Release mode: the medium of the release, used to resolve its canonical detail route.</summary>
|
||||
@@ -27,6 +29,8 @@ public partial class SharePopover : ComponentBase, IDisposable
|
||||
|
||||
[Inject] public required NavigationManager Navigation { get; set; }
|
||||
[Inject] public required IJSRuntime JS { get; set; }
|
||||
[Inject] public required ShareTracker ShareTracker { get; set; }
|
||||
[Inject] public required IAnonIdProvider AnonId { get; set; }
|
||||
|
||||
private bool IsReleaseMode => ReleaseEntryKey is not null;
|
||||
|
||||
@@ -56,10 +60,21 @@ public partial class SharePopover : ComponentBase, IDisposable
|
||||
|
||||
private string TrackUrl => $"{Navigation.BaseUri}tracks/{EntryKey}";
|
||||
|
||||
private string EmbedSnippet =>
|
||||
$"""<iframe src="{Navigation.BaseUri}FramePlayer?TrackEntryKey={EntryKey}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
|
||||
// FramePlayer's query param selects the embed mode: ReleaseEntryKey queues the whole release,
|
||||
// TrackEntryKey stages a single track. The iframe chrome is identical in both modes.
|
||||
private string EmbedSnippet => IsReleaseMode
|
||||
? EmbedSnippetBuilder.ForRelease(Navigation.BaseUri, ReleaseEntryKey!)
|
||||
: EmbedSnippetBuilder.ForTrack(Navigation.BaseUri, EntryKey!);
|
||||
|
||||
private void Toggle() => _open = !_open;
|
||||
private async Task Toggle()
|
||||
{
|
||||
_open = !_open;
|
||||
// Warm the anon-id cache when the popover opens (wave 16.3) so a copy-share fired moments later
|
||||
// reads a populated AnonId.Current. Idempotent and best-effort — if it fails the share simply
|
||||
// carries no anonId. Opening is interactive, so the localStorage interop is available here.
|
||||
if (_open)
|
||||
await AnonId.EnsureLoadedAsync();
|
||||
}
|
||||
|
||||
private void Close() => _open = false;
|
||||
|
||||
@@ -67,6 +82,14 @@ public partial class SharePopover : ComponentBase, IDisposable
|
||||
{
|
||||
if (await CopyToClipboard(LinkUrl))
|
||||
{
|
||||
// Record a share only after the clipboard write succeeds (§1b). Release mode targets the
|
||||
// release EntryKey; track mode targets the track EntryKey. The tracker debounces repeat
|
||||
// copies of the same (target, channel) into one event.
|
||||
if (IsReleaseMode)
|
||||
ShareTracker.RecordShare(ShareTargetType.Release, ReleaseEntryKey!, ShareChannel.Link);
|
||||
else if (!string.IsNullOrWhiteSpace(EntryKey))
|
||||
ShareTracker.RecordShare(ShareTargetType.Track, EntryKey, ShareChannel.Link);
|
||||
|
||||
_linkCopied = true;
|
||||
await ResetAfterDelay(() => _linkCopied = false);
|
||||
}
|
||||
@@ -76,6 +99,11 @@ public partial class SharePopover : ComponentBase, IDisposable
|
||||
{
|
||||
if (await CopyToClipboard(EmbedSnippet))
|
||||
{
|
||||
// Embed is a single-track affordance only (release mode hides it), so this always targets a
|
||||
// track with channel = embed.
|
||||
if (!string.IsNullOrWhiteSpace(EntryKey))
|
||||
ShareTracker.RecordShare(ShareTargetType.Track, EntryKey, ShareChannel.Embed);
|
||||
|
||||
_embedCopied = true;
|
||||
await ResetAfterDelay(() => _embedCopied = false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace DeepDrftPublic.Client.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the iframe embed snippet the share popover copies. Two targets: a single track
|
||||
/// (<see cref="ForTrack"/> → <c>FramePlayer?TrackEntryKey=...</c>) and a whole release
|
||||
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>). The iframe chrome
|
||||
/// (dimensions, border radius, autoplay permission) is identical across both, defined once here.
|
||||
/// Pure string composition so the snippet shape is unit-testable without rendering the component.
|
||||
/// </summary>
|
||||
public static class EmbedSnippetBuilder
|
||||
{
|
||||
// baseUri carries a trailing slash (NavigationManager.BaseUri), so "FramePlayer" appends cleanly.
|
||||
public static string ForTrack(string baseUri, string trackEntryKey)
|
||||
=> Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}");
|
||||
|
||||
public static string ForRelease(string baseUri, string releaseEntryKey)
|
||||
=> Frame($"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}");
|
||||
|
||||
private static string Frame(string src)
|
||||
=> $"""<iframe src="{src}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
<footer class="deepdrft-footer">
|
||||
<div class="deepdrft-footer-logo d-none d-sm-inline">Deep <span>DRFT</span></div>
|
||||
<ul class="deepdrft-footer-links">
|
||||
<li><a href="/about">About</a></li>
|
||||
<li><a href="#">Contact</a></li>
|
||||
</ul>
|
||||
<div class="deepdrft-footer-copy">© 2026 Deep DRFT</div>
|
||||
<div class="deepdrft-footer-main">
|
||||
<div class="deepdrft-footer-logo d-none d-sm-inline">Deep <span>DRFT</span></div>
|
||||
<ul class="deepdrft-footer-links">
|
||||
<li><a href="/about">About</a></li>
|
||||
<li><a href="#">Contact</a></li>
|
||||
</ul>
|
||||
<div class="deepdrft-footer-copy">© 2026 Deep DRFT</div>
|
||||
</div>
|
||||
<p class="deepdrft-footer-privacy">We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag’s gone.</p>
|
||||
</footer>
|
||||
@@ -6,6 +6,12 @@
|
||||
background: var(--deepdrft-white);
|
||||
border-top: 1px solid var(--deepdrft-border);
|
||||
padding: 3rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.deepdrft-footer-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -51,6 +57,16 @@
|
||||
color: var(--deepdrft-muted);
|
||||
}
|
||||
|
||||
.deepdrft-footer-privacy {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--deepdrft-muted);
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 440px) {
|
||||
.deepdrft-footer {
|
||||
padding: 1.5rem;
|
||||
|
||||
@@ -94,6 +94,10 @@ else
|
||||
Play
|
||||
</MudButton>
|
||||
|
||||
@* Append the whole album (TrackNumber order) to the queue — same ordered list
|
||||
header Play uses. Append-only: does not start playback (AC7/AC8). *@
|
||||
<AddToQueueButton ReleaseTracks="@ViewModel.Tracks" />
|
||||
|
||||
@* Release-mode share: copies the canonical /cuts/{entryKey} URL, not a single track (§3b). *@
|
||||
<SharePopover ReleaseEntryKey="@release.EntryKey" ReleaseMedium="@release.Medium" />
|
||||
</div>
|
||||
@@ -138,6 +142,9 @@ else
|
||||
OnToggle="@(() => PlayTrack(track, index))" />
|
||||
</div>
|
||||
<span class="cut-detail-track-name text-truncate">@track.TrackName</span>
|
||||
@* Append this single track to the queue (append-only, does not play). *@
|
||||
<AddToQueueButton Track="@track" />
|
||||
<SharePopover EntryKey="@track.EntryKey" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@using DeepDrftPublic.Client.Controls.AudioPlayerBar
|
||||
@using DeepDrftPublic.Client.Layout
|
||||
@using DeepDrftPublic.Client.Services
|
||||
@using DeepDrftPublic.Client.ViewModels
|
||||
|
||||
@page "/FramePlayer"
|
||||
@layout EmbedLayout
|
||||
@@ -10,29 +11,65 @@
|
||||
|
||||
@code {
|
||||
[CascadingParameter] public IStreamingPlayerService? PlayerService { get; set; }
|
||||
[CascadingParameter] public IQueueService? Queue { get; set; }
|
||||
|
||||
// Two mutually-exclusive embed targets. ReleaseEntryKey wins if both are somehow supplied — a
|
||||
// release embed is the richer surface, and the single-track path would otherwise mask it.
|
||||
[SupplyParameterFromQuery] public string? ReleaseEntryKey { get; set; }
|
||||
[SupplyParameterFromQuery] public string? TrackEntryKey { get; set; }
|
||||
|
||||
[Inject] public required ITrackDataService TrackDataService { get; set; }
|
||||
[Inject] public required FramePlayerViewModel ViewModel { get; set; }
|
||||
|
||||
private string? _stagedKey;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (PlayerService is null || string.IsNullOrWhiteSpace(TrackEntryKey)) return;
|
||||
if (PlayerService is null) return;
|
||||
|
||||
// OnParametersSetAsync can fire repeatedly (and once per render pass); only act when the
|
||||
// key actually changes so we don't re-fetch on every parameter set.
|
||||
if (TrackEntryKey == _stagedKey) return;
|
||||
_stagedKey = TrackEntryKey;
|
||||
if (!string.IsNullOrWhiteSpace(ReleaseEntryKey))
|
||||
{
|
||||
await StageRelease(ReleaseEntryKey);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(TrackEntryKey))
|
||||
{
|
||||
await StageSingleTrack(TrackEntryKey);
|
||||
}
|
||||
}
|
||||
|
||||
var result = await TrackDataService.GetTrack(TrackEntryKey);
|
||||
// Release embed: resolve the release's ordered tracks, stage the first so the bar shows the release
|
||||
// ready, and arm the queue with the whole list. No JS interop here (StageTrack and Arm are both
|
||||
// interop-free), so this runs identically during prerender and after WASM boot. The first play
|
||||
// click (handled in AudioPlayerBar) routes through Queue.PlayRelease, queueing the full release.
|
||||
private async Task StageRelease(string releaseEntryKey)
|
||||
{
|
||||
// OnParametersSetAsync can fire repeatedly; only act when the key actually changes.
|
||||
if (releaseEntryKey == _stagedKey) return;
|
||||
_stagedKey = releaseEntryKey;
|
||||
|
||||
await ViewModel.Load(releaseEntryKey);
|
||||
if (ViewModel.Tracks.Count == 0) return; // No tracks: leave the bar idle.
|
||||
|
||||
await PlayerService!.StageTrack(ViewModel.Tracks[0]);
|
||||
Queue?.Arm(ViewModel.Tracks);
|
||||
}
|
||||
|
||||
// Single-track embed: unchanged behaviour — stage exactly the requested track. The first play
|
||||
// click streams it directly (the queue stays empty/disarmed).
|
||||
private async Task StageSingleTrack(string trackEntryKey)
|
||||
{
|
||||
if (trackEntryKey == _stagedKey) return;
|
||||
_stagedKey = trackEntryKey;
|
||||
|
||||
var result = await TrackDataService.GetTrack(trackEntryKey);
|
||||
if (result.Success && result.Value is not null)
|
||||
{
|
||||
// Stage only — no audio context, no streaming. The browser blocks audio until a user
|
||||
// gesture, so the embed shows the track ready and the first play click (handled in
|
||||
// AudioPlayerBar) calls SelectTrackStreaming. This also keeps this pass free of JS
|
||||
// interop, so it works whether it runs during prerender or after WASM is interactive.
|
||||
await PlayerService.StageTrack(result.Value);
|
||||
// gesture, so the embed shows the track ready and the first play click calls
|
||||
// SelectTrackStreaming. This keeps this pass free of JS interop, so it works whether it
|
||||
// runs during prerender or after WASM is interactive.
|
||||
await PlayerService!.StageTrack(result.Value);
|
||||
}
|
||||
// On failure, leave the bar idle; a stream-level error surfaces via PlayerService.ErrorMessage.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ else
|
||||
@if (ViewModel.Track is not null)
|
||||
{
|
||||
<PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
@* Append-only: queues the mix's single track without starting playback. *@
|
||||
<AddToQueueButton Track="@ViewModel.Track" Size="Size.Large" />
|
||||
}
|
||||
</PlayContent>
|
||||
</ReleaseHeroOverlay>
|
||||
|
||||
@@ -79,6 +79,8 @@ else
|
||||
@if (ViewModel.Track is not null)
|
||||
{
|
||||
<PlayStateIcon Track="@ViewModel.Track" Size="Size.Large" Color="Color.Secondary" OnToggle="@PlayTrack" />
|
||||
@* Append-only: queues the session's single track without starting playback. *@
|
||||
<AddToQueueButton Track="@ViewModel.Track" Size="Size.Large" />
|
||||
}
|
||||
</PlayContent>
|
||||
</ReleaseHeroOverlay>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IAnonIdProvider"/> over the <c>window.DeepDrftAnonId</c> TS interop. Reads the
|
||||
/// first-party <c>localStorage</c> GUID once and caches it for the session, so the synchronous emit paths
|
||||
/// read it with no JS hop. Scoped (per-session) like the other telemetry collaborators; the underlying
|
||||
/// token itself outlives the session in <c>localStorage</c> — the cache just avoids repeated interop.
|
||||
/// </summary>
|
||||
public sealed class AnonIdProvider : IAnonIdProvider
|
||||
{
|
||||
private readonly IJSRuntime _js;
|
||||
private bool _loaded;
|
||||
|
||||
public AnonIdProvider(IJSRuntime js)
|
||||
{
|
||||
_js = js;
|
||||
}
|
||||
|
||||
public string? Current { get; private set; }
|
||||
|
||||
public async ValueTask EnsureLoadedAsync()
|
||||
{
|
||||
if (_loaded) return;
|
||||
|
||||
try
|
||||
{
|
||||
// The module returns null when localStorage is unavailable; we store that null and still
|
||||
// mark loaded so we don't retry every emit. A genuine interop failure (module not yet
|
||||
// imported, prerender) is caught below and leaves _loaded false so a later warm can succeed.
|
||||
Current = await _js.InvokeAsync<string?>("DeepDrftAnonId.get");
|
||||
_loaded = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Interop unavailable (prerender / module not loaded). Leave Current null and unloaded so a
|
||||
// subsequent warm retries — telemetry simply omits the id until then.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,9 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
private async Task OnProgressCallback(double currentTime)
|
||||
{
|
||||
CurrentTime = currentTime;
|
||||
// Telemetry hook (Phase 16 §2.1): a subclass advances the play-session high-water mark here, on
|
||||
// the same throttled tick the UI already consumes. Base implementation is a no-op.
|
||||
OnProgressTick(currentTime);
|
||||
await NotifyStateChanged();
|
||||
}
|
||||
|
||||
@@ -270,6 +273,10 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
IsLoaded = false;
|
||||
CurrentTime = 0;
|
||||
Duration = null;
|
||||
// Telemetry hook: organic end closes the play session (the bucket reflects how far they got)
|
||||
// BEFORE the state notification and TrackEnded fan-out, so the session that just ended is the
|
||||
// one recorded — not whatever a queue auto-advance opens next. Base implementation is a no-op.
|
||||
OnPlaybackEnded();
|
||||
await NotifyStateChanged();
|
||||
|
||||
// Fire AFTER the state notification so any queue orchestrator that advances on this
|
||||
@@ -279,6 +286,18 @@ public abstract class AudioPlayerService : IPlayerService, IAsyncDisposable
|
||||
TrackEnded?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry seam (Phase 16): called on each progress tick with the current playback position. The
|
||||
/// streaming subclass overrides this to advance the play-session high-water mark. No-op in the base.
|
||||
/// </summary>
|
||||
protected virtual void OnProgressTick(double currentTime) { }
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry seam (Phase 16): called on organic end-of-stream, before <see cref="TrackEnded"/> fires.
|
||||
/// The streaming subclass overrides this to close the play session. No-op in the base.
|
||||
/// </summary>
|
||||
protected virtual void OnPlaybackEnded() { }
|
||||
|
||||
|
||||
protected async Task EnsureInitializedAsync()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin C# wrapper over the <c>window.DeepDrftBeacon</c> TS interop (Phase 16 §2.2). Wraps the
|
||||
/// <c>navigator.sendBeacon</c> POST and the page-unload registration so the rest of the client never
|
||||
/// touches <see cref="IJSRuntime"/> string identifiers directly. All calls are best-effort: a JS
|
||||
/// failure (module not yet loaded, interop unavailable during prerender) is swallowed — telemetry must
|
||||
/// never throw into the UI or the playback path.
|
||||
/// </summary>
|
||||
public sealed class BeaconInterop
|
||||
{
|
||||
private readonly IJSRuntime _js;
|
||||
|
||||
public BeaconInterop(IJSRuntime js)
|
||||
{
|
||||
_js = js;
|
||||
}
|
||||
|
||||
/// <summary>Queue a fire-and-forget POST of a JSON body to the given absolute URL.</summary>
|
||||
public async Task SendAsync(string url, string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _js.InvokeAsync<bool>("DeepDrftBeacon.send", url, json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Module not loaded / not interactive yet — drop the event silently.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Register a .NET unload callback (fires on pagehide / visibility→hidden) under a key.</summary>
|
||||
public async Task RegisterUnloadAsync<T>(string key, DotNetObjectReference<T> dotNetRef, string methodName)
|
||||
where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
await _js.InvokeVoidAsync("DeepDrftBeacon.registerUnload", key, dotNetRef, methodName);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best-effort — without the unload handler, mid-play tab-close simply isn't recorded.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Detach a previously-registered unload callback.</summary>
|
||||
public async Task UnregisterUnloadAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _js.InvokeVoidAsync("DeepDrftBeacon.unregisterUnload", key);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposal best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IPlayEventSink"/> (Phase 16 §2.2): serializes the play classification and fires
|
||||
/// it via <c>navigator.sendBeacon</c> to the proxied <c>api/event/play</c> route. Fire-and-forget by
|
||||
/// design — <see cref="IPlayEventSink.EmitPlay"/> is synchronous (it is called from the player's close
|
||||
/// path and the unload handler, neither of which can await), so the beacon is dispatched without
|
||||
/// awaiting and its failure is irrelevant. The current <c>anonId</c> (wave 16.3) is read synchronously
|
||||
/// from the warmed <see cref="IAnonIdProvider"/> cache and omitted when null (storage unavailable / not
|
||||
/// yet warmed) — an anonId-less play still counts, it just doesn't contribute to the listener tally.
|
||||
/// </summary>
|
||||
public sealed class BeaconPlayEventSink : IPlayEventSink
|
||||
{
|
||||
// Omit a null anonId from the wire payload (§2.2 — "omitted entirely" when absent) rather than
|
||||
// sending "anonId":null. The API treats absent and null identically, so this is cosmetic minimalism;
|
||||
// it does not change the integer enum encoding the 16.1 contract already relies on.
|
||||
private static readonly JsonSerializerOptions BeaconJson =
|
||||
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
|
||||
|
||||
private readonly BeaconInterop _beacon;
|
||||
private readonly IAnonIdProvider _anonId;
|
||||
private readonly string _playUrl;
|
||||
|
||||
public BeaconPlayEventSink(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
|
||||
{
|
||||
_beacon = beacon;
|
||||
_anonId = anonId;
|
||||
// The WASM client posts to its own host, which proxies to DeepDrftAPI. BaseUri carries a
|
||||
// trailing slash; the route does not lead with one.
|
||||
_playUrl = $"{navigation.BaseUri}api/event/play";
|
||||
}
|
||||
|
||||
public void EmitPlay(string trackEntryKey, PlayBucket bucket)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(new PlayEventDto
|
||||
{
|
||||
TrackEntryKey = trackEntryKey,
|
||||
Bucket = bucket,
|
||||
AnonId = _anonId.Current,
|
||||
}, BeaconJson);
|
||||
|
||||
// Fire-and-forget: do not await. The beacon survives unload; the C# task may not, and we do not
|
||||
// act on the result either way.
|
||||
_ = _beacon.SendAsync(_playUrl, json);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Supplies the client-minted anonymous listener id (Phase 16 §3, wave 16.3, D5 Option A) to the
|
||||
/// fire-and-forget telemetry sinks. The id is a random first-party <c>localStorage</c> GUID — opaque, no
|
||||
/// PII, no fingerprinting, clearable. Split into an async warm (<see cref="EnsureLoadedAsync"/>) and a
|
||||
/// synchronous read (<see cref="Current"/>) so the existing sync emit paths (the beacon sink, the share
|
||||
/// tracker) need no async signature change: a caller warms the cache when it goes interactive, and the
|
||||
/// emit then reads the cached value with no JS round-trip on the close/unload path.
|
||||
///
|
||||
/// <para>
|
||||
/// Degrades to null when <c>localStorage</c> is unavailable (private mode / blocked / partitioned
|
||||
/// third-party iframe) — the sink then omits the id and sends an anonId-less event. Over-counting is the
|
||||
/// accepted direction of error (§3); a missing id never throws.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public interface IAnonIdProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// The cached anon id, or null if not yet warmed or if storage is unavailable. Synchronous and safe
|
||||
/// to read from the player close path and the page-unload handler, neither of which can await.
|
||||
/// </summary>
|
||||
string? Current { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Warm the cache from <c>localStorage</c> via JS interop (minting on first visit). Idempotent — only
|
||||
/// the first successful read populates the cache; later calls are no-ops. Best-effort: a JS failure
|
||||
/// (interop unavailable during prerender, storage blocked) leaves <see cref="Current"/> null and never
|
||||
/// throws.
|
||||
/// </summary>
|
||||
ValueTask EnsureLoadedAsync();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// The emit seam for the <see cref="PlayTracker"/> (Phase 16 §2.1). The tracker owns the session
|
||||
/// lifecycle, the engagement floor, and the bucket classification but knows nothing about transport —
|
||||
/// it hands a finished classification to a sink. The production sink fires a <c>sendBeacon</c> POST to
|
||||
/// <c>api/event/play</c>; tests substitute a fake sink to assert floor and bucket behaviour with no
|
||||
/// JS interop. This keeps the tracker's logic testable behind one seam, as the spec calls for.
|
||||
/// </summary>
|
||||
public interface IPlayEventSink
|
||||
{
|
||||
/// <summary>Emit one recorded play. Called at most once per session, only when the floor is crossed.</summary>
|
||||
void EmitPlay(string trackEntryKey, PlayBucket bucket);
|
||||
}
|
||||
@@ -38,6 +38,15 @@ public interface IQueueService
|
||||
/// <summary>The current track, or null when the queue is empty.</summary>
|
||||
TrackDto? Current { get; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the queue has been loaded via <see cref="Arm"/> but no track has streamed yet —
|
||||
/// the embed's pre-gesture state. Set by <see cref="Arm"/>; cleared the moment playback actually
|
||||
/// starts (<see cref="Start"/>/<see cref="PlayRelease"/>/<see cref="Next"/>/<see cref="Previous"/>)
|
||||
/// or on <see cref="Clear"/>. The player bar reads this to route the first play gesture through
|
||||
/// <see cref="Start"/> (which begins the armed release) rather than streaming the staged track alone.
|
||||
/// </summary>
|
||||
bool IsArmed { get; }
|
||||
|
||||
/// <summary>True when there is a track after <see cref="CurrentIndex"/> to advance to.</summary>
|
||||
bool HasNext { get; }
|
||||
|
||||
@@ -59,12 +68,64 @@ public interface IQueueService
|
||||
/// </summary>
|
||||
Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0);
|
||||
|
||||
/// <summary>Appends a track to the end of the queue without changing what is currently playing.</summary>
|
||||
/// <summary>
|
||||
/// Loads <paramref name="tracks"/> as the queue and sets the current position to index 0 WITHOUT
|
||||
/// streaming anything — the queue is "armed". This is the embed's prerender-safe entry point: it
|
||||
/// performs no JS interop, so it runs identically during prerender and after WASM boot. The first
|
||||
/// play gesture (see <see cref="IsArmed"/>) then starts playback via <see cref="Start"/>, which
|
||||
/// keeps the loaded release queued so it advances through its tracks. No-op when
|
||||
/// <paramref name="tracks"/> is empty (the queue stays empty and disarmed).
|
||||
/// </summary>
|
||||
void Arm(IEnumerable<TrackDto> tracks);
|
||||
|
||||
/// <summary>
|
||||
/// Begins playback of an armed queue (see <see cref="Arm"/>): streams the current track and clears
|
||||
/// <see cref="IsArmed"/>, leaving the loaded list and position intact so auto-advance carries on
|
||||
/// through the release. This is the first-gesture entry point the embed bar calls. No-op (and stays
|
||||
/// disarmed) when the queue is not armed or is empty — so it never double-streams or disturbs a
|
||||
/// queue already playing.
|
||||
/// </summary>
|
||||
Task Start();
|
||||
|
||||
/// <summary>
|
||||
/// Appends a track to the end of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
|
||||
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
|
||||
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
|
||||
/// </summary>
|
||||
void Enqueue(TrackDto track);
|
||||
|
||||
/// <summary>Appends tracks to the end of the queue without changing what is currently playing.</summary>
|
||||
/// <summary>
|
||||
/// Appends tracks to the end of the queue without changing what is currently playing.
|
||||
/// Into a dormant queue (<see cref="CurrentIndex"/> == -1) the append leaves a coherent
|
||||
/// <see cref="CurrentIndex"/> (the first appended track) so a subsequent play/skip is correct —
|
||||
/// but it does NOT begin playback (add is not play). Interop-free; safe during prerender.
|
||||
/// </summary>
|
||||
void EnqueueRange(IEnumerable<TrackDto> tracks);
|
||||
|
||||
/// <summary>
|
||||
/// Reorders the queue, moving the track at <paramref name="fromIndex"/> to
|
||||
/// <paramref name="toIndex"/>, and re-emits <see cref="QueueChanged"/>. Adjusts
|
||||
/// <see cref="CurrentIndex"/> so the <em>same track</em> stays current across the move — it does
|
||||
/// not restart, re-stream, or interrupt the currently-playing track. Interop-free; safe during
|
||||
/// prerender. No-op (no throw, no <see cref="QueueChanged"/>) when either index is out of range
|
||||
/// or the indices are equal.
|
||||
/// </summary>
|
||||
void Move(int fromIndex, int toIndex);
|
||||
|
||||
/// <summary>
|
||||
/// Removes the track at <paramref name="index"/> and re-emits <see cref="QueueChanged"/>. Does
|
||||
/// not touch playback (the player stays a single-track device): removing the current track does
|
||||
/// not stop it — the playing track runs to its natural end while <see cref="CurrentIndex"/>
|
||||
/// resolves to the new occupant of that slot (the next track) so the next auto-advance/skip is
|
||||
/// coherent. Removing a track before the current decrements <see cref="CurrentIndex"/> (the same
|
||||
/// track stays current); removing after the current leaves it unchanged. Removing the last
|
||||
/// remaining track empties the queue (<see cref="CurrentIndex"/> == -1, dormant). Interop-free;
|
||||
/// safe during prerender. No-op (no throw, no <see cref="QueueChanged"/>) when
|
||||
/// <paramref name="index"/> is out of range.
|
||||
/// </summary>
|
||||
void RemoveAt(int index);
|
||||
|
||||
/// <summary>
|
||||
/// Advances to the next track and streams it. No-op when <see cref="HasNext"/> is false.
|
||||
/// </summary>
|
||||
@@ -77,4 +138,15 @@ public interface IQueueService
|
||||
|
||||
/// <summary>Empties the queue and resets the position. Does not stop the player.</summary>
|
||||
void Clear();
|
||||
|
||||
/// <summary>
|
||||
/// Empties the up-next while keeping the currently-playing track: removes every item except
|
||||
/// <see cref="Current"/>, leaving it as the sole remaining item at <see cref="CurrentIndex"/> == 0,
|
||||
/// and re-emits <see cref="QueueChanged"/>. Unlike <see cref="Clear"/> (which empties everything and
|
||||
/// goes dormant), this preserves what is playing — the player is never stopped and the current track
|
||||
/// stays queued, so playback continues uninterrupted while the rest of the queue is discarded.
|
||||
/// Interop-free; safe during prerender. No-op (no throw, no <see cref="QueueChanged"/>) when the queue
|
||||
/// is empty/dormant or already holds only the current track.
|
||||
/// </summary>
|
||||
void ClearUpcoming();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
using DeepDrftModels.Enums;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Per-session play tracker (Phase 16 §2.1). Observes the player-service playback lifecycle — open on
|
||||
/// playback start, advance the high-water mark on each progress tick, close on organic end / track-switch
|
||||
/// / stop / page-unload — and emits at most one play event per session, classified into a completion
|
||||
/// bucket, but only once the engagement floor is crossed (§1d / D2).
|
||||
///
|
||||
/// <para>
|
||||
/// Deliberately free of any player, HTTP, or JS dependency: it takes an <see cref="IPlayEventSink"/> and
|
||||
/// owns only session state and the floor/classification arithmetic, so its behaviour is unit-testable
|
||||
/// against a fake sink with no interop (the spec's "testable behind one seam"). The production sink fires
|
||||
/// the beacon. Instrumented at the player-service level ONLY — never the HTTP/media client — so a
|
||||
/// seek-beyond-buffer re-fetch is the same play, not a new one (§1d).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>Not thread-safe: the WASM dispatcher is single-threaded, and every call originates there.</para>
|
||||
/// </summary>
|
||||
public sealed class PlayTracker
|
||||
{
|
||||
// Engagement floor (§1d / D2): a listen counts only once playback reaches at least 3 seconds OR
|
||||
// 5% of duration, whichever is SMALLER — so a sub-60s clip floors on the percentage and anything
|
||||
// longer floors on the 3-second wall. Single tunable constant pair; one place to retune.
|
||||
private const double FloorSeconds = 3.0;
|
||||
private const double FloorFraction = 0.05;
|
||||
|
||||
// Bucket thresholds (§1a / D1): partial [0, 30%), sampled [30%, 80%], complete (80%, 100%].
|
||||
private const double SampledThreshold = 0.30;
|
||||
private const double CompleteThreshold = 0.80;
|
||||
|
||||
private readonly IPlayEventSink _sink;
|
||||
|
||||
private string? _trackEntryKey;
|
||||
private double? _duration;
|
||||
private double _highWater;
|
||||
private bool _closed;
|
||||
|
||||
public PlayTracker(IPlayEventSink sink)
|
||||
{
|
||||
_sink = sink;
|
||||
}
|
||||
|
||||
/// <summary>True while a session is open (playback started, not yet closed). Drives the unload beacon.</summary>
|
||||
public bool HasOpenSession => _trackEntryKey is not null && !_closed;
|
||||
|
||||
/// <summary>
|
||||
/// Open a session for the track whose playback just started. Supersedes any still-open session by
|
||||
/// closing it first — a track-switch that did not route through <see cref="Close"/> still records the
|
||||
/// prior listen. Duration is unknown at open and arrives later via <see cref="SetDuration"/>.
|
||||
/// </summary>
|
||||
public void OnPlaybackStarted(string trackEntryKey)
|
||||
{
|
||||
if (HasOpenSession)
|
||||
Close();
|
||||
|
||||
_trackEntryKey = trackEntryKey;
|
||||
_duration = null;
|
||||
_highWater = 0;
|
||||
_closed = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record the duration once the WAV header has set it. Idempotent — only the first non-positive-guarded
|
||||
/// value is taken, matching the player which sets <c>Duration</c> exactly once.
|
||||
/// </summary>
|
||||
public void SetDuration(double durationSeconds)
|
||||
{
|
||||
if (!HasOpenSession) return;
|
||||
if (_duration is null && durationSeconds > 0)
|
||||
_duration = durationSeconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advance the high-water mark from a progress tick. Monotonic — seeking backward never lowers it,
|
||||
/// so a seek-to-end-then-back still classifies by the furthest point reached (§1d).
|
||||
/// </summary>
|
||||
public void OnProgress(double currentTime)
|
||||
{
|
||||
if (!HasOpenSession) return;
|
||||
if (currentTime > _highWater)
|
||||
_highWater = currentTime;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Close the open session and emit a play event if the engagement floor was crossed; below the floor
|
||||
/// nothing is sent (it was a preview/skip, §1d). Idempotent and safe to call when no session is open —
|
||||
/// organic end, track-switch, stop, dispose, and the unload beacon may all race to close, and only the
|
||||
/// first call emits.
|
||||
/// </summary>
|
||||
public void Close()
|
||||
{
|
||||
if (!HasOpenSession)
|
||||
{
|
||||
// Mark closed even if never opened so a stray late callback cannot reopen-then-emit.
|
||||
_closed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
var key = _trackEntryKey!;
|
||||
_closed = true;
|
||||
|
||||
// Without a known duration there is no fraction to classify and no floor to test — drop the
|
||||
// session. In practice the WAV header sets duration well before any meaningful listen, so this
|
||||
// only drops listens that ended before the header parsed (i.e. effectively no listen).
|
||||
if (_duration is not { } duration || duration <= 0)
|
||||
return;
|
||||
|
||||
var fraction = Math.Clamp(_highWater / duration, 0.0, 1.0);
|
||||
|
||||
if (!CrossesFloor(_highWater, duration))
|
||||
return;
|
||||
|
||||
_sink.EmitPlay(key, Classify(fraction));
|
||||
}
|
||||
|
||||
// The floor is the SMALLER of the absolute-seconds wall and the percentage of duration (§1d / D2).
|
||||
private static bool CrossesFloor(double highWater, double duration)
|
||||
{
|
||||
var floor = Math.Min(FloorSeconds, FloorFraction * duration);
|
||||
return highWater >= floor;
|
||||
}
|
||||
|
||||
private static PlayBucket Classify(double fraction)
|
||||
=> fraction < SampledThreshold ? PlayBucket.Partial
|
||||
: fraction <= CompleteThreshold ? PlayBucket.Sampled
|
||||
: PlayBucket.Complete;
|
||||
}
|
||||
@@ -26,6 +26,8 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
|
||||
public int CurrentIndex { get; private set; } = -1;
|
||||
|
||||
public bool IsArmed { get; private set; }
|
||||
|
||||
public TrackDto? Current =>
|
||||
CurrentIndex >= 0 && CurrentIndex < _items.Count ? _items[CurrentIndex] : null;
|
||||
|
||||
@@ -54,7 +56,7 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
|
||||
public async Task PlayRelease(IEnumerable<TrackDto> tracks, int startIndex = 0)
|
||||
{
|
||||
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
||||
var list = tracks.ToList();
|
||||
if (list.Count == 0) return;
|
||||
|
||||
var start = Math.Clamp(startIndex, 0, list.Count - 1);
|
||||
@@ -62,14 +64,42 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
_items.Clear();
|
||||
_items.AddRange(list);
|
||||
CurrentIndex = start;
|
||||
// Playback is now starting for real, so the queue is no longer merely armed.
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public void Arm(IEnumerable<TrackDto> tracks)
|
||||
{
|
||||
var list = tracks as IReadOnlyList<TrackDto> ?? tracks.ToList();
|
||||
if (list.Count == 0) return;
|
||||
|
||||
_items.Clear();
|
||||
_items.AddRange(list);
|
||||
CurrentIndex = 0;
|
||||
IsArmed = true;
|
||||
// No PlayCurrent: arming is interop-free state only. The first play gesture drives Start().
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public async Task Start()
|
||||
{
|
||||
if (!IsArmed) return;
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
|
||||
public void Enqueue(TrackDto track)
|
||||
{
|
||||
_items.Add(track);
|
||||
// OQ8: appending into a dormant (empty) queue leaves a coherent CurrentIndex so the next
|
||||
// play/skip is correct — but does NOT auto-play (add is not play). PlayCurrent is never
|
||||
// called here, so this stays interop-free and prerender-safe.
|
||||
if (CurrentIndex == -1)
|
||||
CurrentIndex = 0;
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
@@ -77,14 +107,78 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
{
|
||||
var before = _items.Count;
|
||||
_items.AddRange(tracks);
|
||||
if (_items.Count != before)
|
||||
QueueChanged?.Invoke();
|
||||
if (_items.Count == before) return;
|
||||
// OQ8: see Enqueue — first append into a dormant queue stages a coherent CurrentIndex
|
||||
// without playing. The first newly-appended track becomes current.
|
||||
if (CurrentIndex == -1)
|
||||
CurrentIndex = 0;
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void Move(int fromIndex, int toIndex)
|
||||
{
|
||||
if (fromIndex == toIndex) return;
|
||||
if (fromIndex < 0 || fromIndex >= _items.Count) return;
|
||||
if (toIndex < 0 || toIndex >= _items.Count) return;
|
||||
|
||||
var moved = _items[fromIndex];
|
||||
_items.RemoveAt(fromIndex);
|
||||
_items.Insert(toIndex, moved);
|
||||
|
||||
// Keep the same track current across the reorder. No playback is touched (C2): we only
|
||||
// recompute which index the current track now sits at.
|
||||
if (CurrentIndex == fromIndex)
|
||||
{
|
||||
CurrentIndex = toIndex;
|
||||
}
|
||||
else if (fromIndex < CurrentIndex && toIndex >= CurrentIndex)
|
||||
{
|
||||
// The current track shifted one slot toward the front to fill the vacated lower slot.
|
||||
CurrentIndex--;
|
||||
}
|
||||
else if (fromIndex > CurrentIndex && toIndex <= CurrentIndex)
|
||||
{
|
||||
// An item inserted at/above the current slot pushed the current track one slot back.
|
||||
CurrentIndex++;
|
||||
}
|
||||
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void RemoveAt(int index)
|
||||
{
|
||||
if (index < 0 || index >= _items.Count) return;
|
||||
|
||||
_items.RemoveAt(index);
|
||||
|
||||
if (_items.Count == 0)
|
||||
{
|
||||
// Last remaining track removed → empty + dormant. Does not stop the player (C2).
|
||||
CurrentIndex = -1;
|
||||
}
|
||||
else if (index < CurrentIndex)
|
||||
{
|
||||
// A track before the current was removed: the same track stays current at a lower index.
|
||||
CurrentIndex--;
|
||||
}
|
||||
else if (index == CurrentIndex && CurrentIndex >= _items.Count)
|
||||
{
|
||||
// The current track was removed and it was the last slot: there is no "next" occupant to
|
||||
// resolve to, so the queue goes dormant (CurrentIndex == -1). Playback is NOT stopped
|
||||
// (C2) — the just-removed track keeps playing to its natural end; auto-advance simply has
|
||||
// nothing further. Removing current when it is NOT the last leaves CurrentIndex pointing
|
||||
// at the new occupant of that slot (the next track), so no adjustment is needed there.
|
||||
CurrentIndex = -1;
|
||||
}
|
||||
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public async Task Next()
|
||||
{
|
||||
if (!HasNext) return;
|
||||
CurrentIndex++;
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
@@ -93,6 +187,7 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
{
|
||||
if (!HasPrevious) return;
|
||||
CurrentIndex--;
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
await PlayCurrent();
|
||||
}
|
||||
@@ -102,6 +197,21 @@ public sealed class QueueService : IQueueService, IDisposable
|
||||
if (_items.Count == 0 && CurrentIndex == -1) return;
|
||||
_items.Clear();
|
||||
CurrentIndex = -1;
|
||||
IsArmed = false;
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
public void ClearUpcoming()
|
||||
{
|
||||
// Keep the currently-playing track, drop everything else. No current track (dormant/empty) or a
|
||||
// queue that already holds only the current → nothing to clear.
|
||||
var current = Current;
|
||||
if (current is null || _items.Count <= 1) return;
|
||||
|
||||
_items.Clear();
|
||||
_items.Add(current);
|
||||
CurrentIndex = 0;
|
||||
// Playback is untouched (C2): the current track keeps streaming; we only discarded the up-next.
|
||||
QueueChanged?.Invoke();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Records share events from <c>SharePopover</c> (Phase 16 §1b / §2.1). After a successful clipboard
|
||||
/// write the popover calls <see cref="RecordShare"/>; this tracker applies the per-(target,channel)
|
||||
/// debounce — at most one event per target+channel per <see cref="DebounceWindow"/> per session — and
|
||||
/// fires the event via <c>navigator.sendBeacon</c> to the proxied <c>api/event/share</c> route.
|
||||
///
|
||||
/// <para>
|
||||
/// Scoped (per-session) so the debounce memory lives for the session and resets on a fresh load, matching
|
||||
/// the "feels like one act" intent: copying the same link three times in a row is one share, not three.
|
||||
/// The beacon send is fire-and-forget; the current <c>anonId</c> (wave 16.3) is read synchronously from
|
||||
/// the warmed <see cref="IAnonIdProvider"/> cache and omitted when null.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class ShareTracker
|
||||
{
|
||||
// One event per (target, channel) per this window per session (§1b). 60s matches the spec's
|
||||
// recommendation — long enough to fold a flurry of repeat copies into one intent.
|
||||
private static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(60);
|
||||
|
||||
// Omit a null anonId from the wire payload (§2.2). Cosmetic — the API tolerates null — and does not
|
||||
// change the integer enum encoding the 16.1 contract relies on.
|
||||
private static readonly JsonSerializerOptions BeaconJson =
|
||||
new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull };
|
||||
|
||||
private readonly BeaconInterop _beacon;
|
||||
private readonly IAnonIdProvider _anonId;
|
||||
private readonly string _shareUrl;
|
||||
private readonly Dictionary<string, DateTimeOffset> _lastSent = new();
|
||||
|
||||
public ShareTracker(BeaconInterop beacon, IAnonIdProvider anonId, NavigationManager navigation)
|
||||
{
|
||||
_beacon = beacon;
|
||||
_anonId = anonId;
|
||||
_shareUrl = $"{navigation.BaseUri}api/event/share";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a share unless an identical (target, channel) was recorded within the debounce window.
|
||||
/// Returns true when an event was fired, false when debounced — primarily so tests can assert the
|
||||
/// debounce without reaching into the beacon.
|
||||
/// </summary>
|
||||
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel)
|
||||
=> RecordShare(targetType, targetKey, channel, DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>
|
||||
/// Debounce-aware record with an injectable <paramref name="now"/> so the 60s window is testable
|
||||
/// without wall-clock waits. The parameterless overload above passes <see cref="DateTimeOffset.UtcNow"/>.
|
||||
/// </summary>
|
||||
public bool RecordShare(ShareTargetType targetType, string targetKey, ShareChannel channel, DateTimeOffset now)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetKey))
|
||||
return false;
|
||||
|
||||
var dedupeKey = $"{targetType}:{targetKey}:{channel}";
|
||||
if (_lastSent.TryGetValue(dedupeKey, out var last) && now - last < DebounceWindow)
|
||||
return false;
|
||||
|
||||
_lastSent[dedupeKey] = now;
|
||||
|
||||
var json = JsonSerializer.Serialize(new ShareEventDto
|
||||
{
|
||||
TargetType = targetType,
|
||||
TargetKey = targetKey,
|
||||
Channel = channel,
|
||||
AnonId = _anonId.Current,
|
||||
}, BeaconJson);
|
||||
|
||||
// Fire-and-forget — a dropped share telemetry event is acceptable.
|
||||
_ = _beacon.SendAsync(_shareUrl, json);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Clients;
|
||||
using System.Buffers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftPublic.Client.Services;
|
||||
|
||||
@@ -32,6 +33,22 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
private readonly ILogger<StreamingAudioPlayerService> _logger;
|
||||
private string? _currentTrackId;
|
||||
|
||||
// Phase 16 play-session telemetry (§2.1). The tracker observes the playback lifecycle and emits at
|
||||
// most one bucketed play event per session, behind the engagement floor. Attached after construction
|
||||
// by AudioPlayerProvider (the player is not DI-registered), mirroring how QueueService binds — no
|
||||
// constructor growth propagated through DI, no construction cycle. Null when telemetry is not wired
|
||||
// (e.g. unit tests that construct the player without it), so every call is null-guarded.
|
||||
private PlayTracker? _playTracker;
|
||||
private BeaconInterop? _beacon;
|
||||
private DotNetObjectReference<StreamingAudioPlayerService>? _unloadRef;
|
||||
private string? _unloadKey;
|
||||
|
||||
// One-shot guard so the play session opens exactly once per LoadTrackStreaming — never on the
|
||||
// SeekBeyondBuffer re-stream, which reuses _currentTrackId and re-runs the playback-start transition
|
||||
// with _streamingPlaybackStarted reset. A seek-beyond-buffer is the SAME play (§1d), so it must not
|
||||
// open a new session. Set true when the session opens; reset only by LoadTrackStreaming.
|
||||
private bool _sessionOpened;
|
||||
|
||||
public StreamingAudioPlayerService(
|
||||
AudioInteropService audioInterop,
|
||||
TrackMediaClient trackMediaClient,
|
||||
@@ -41,6 +58,41 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wire the play-session tracker and beacon transport into the player after construction (Phase 16
|
||||
/// §2.1). Called once by <c>AudioPlayerProvider</c>. Kept off the constructor deliberately: the player
|
||||
/// is built with <c>new</c> by the provider (not DI), so threading the tracker through the constructor
|
||||
/// would force the provider to resolve it too — instead the provider injects the tracker's collaborators
|
||||
/// and hands a built tracker here, the same post-construction binding QueueService uses. Also registers
|
||||
/// the page-unload handler so a mid-play tab-close still records the play via sendBeacon.
|
||||
/// </summary>
|
||||
public void AttachTracker(PlayTracker tracker, BeaconInterop beacon)
|
||||
{
|
||||
_playTracker = tracker;
|
||||
_beacon = beacon;
|
||||
|
||||
_unloadRef = DotNetObjectReference.Create(this);
|
||||
_unloadKey = PlayerId;
|
||||
// Fire-and-forget: registration only needs to have happened before the listener leaves; it
|
||||
// never gates playback. A failure simply means tab-close mid-play isn't recorded.
|
||||
_ = _beacon.RegisterUnloadAsync(_unloadKey, _unloadRef, nameof(OnPageUnload));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Close the open play session as the page unloads (pagehide / visibility→hidden). Invoked
|
||||
/// synchronously from the beacon's unload handler so the session's beacon is queued before the page
|
||||
/// freezes. <see cref="PlayTracker.Close"/> is idempotent, so a later organic close is a no-op.
|
||||
/// </summary>
|
||||
[JSInvokable]
|
||||
public void OnPageUnload() => _playTracker?.Close();
|
||||
|
||||
// Advance the play-session high-water mark on each progress tick (§2.1). Seeking backward never
|
||||
// lowers it — the tracker takes the max.
|
||||
protected override void OnProgressTick(double currentTime) => _playTracker?.OnProgress(currentTime);
|
||||
|
||||
// Organic end-of-stream closes the session; the bucket reflects the high-water fraction reached.
|
||||
protected override void OnPlaybackEnded() => _playTracker?.Close();
|
||||
|
||||
public override async Task SelectTrack(TrackDto track)
|
||||
{
|
||||
await SelectTrackStreaming(track);
|
||||
@@ -88,6 +140,10 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
|
||||
// Save track ID for seek operations
|
||||
_currentTrackId = track.EntryKey;
|
||||
// A fresh load is a fresh play candidate (§1d: replays = multiple plays). Arm the
|
||||
// one-shot session-open guard; the session actually opens at the playback-start transition
|
||||
// below (a track that fails to load never reaches it, so it does not count).
|
||||
_sessionOpened = false;
|
||||
// Expose to UI immediately — Now-Playing surfaces should reflect the selected
|
||||
// track while it's still loading, not only after playback starts.
|
||||
CurrentTrack = track;
|
||||
@@ -303,8 +359,12 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
{
|
||||
Duration = chunkResult.Duration.Value;
|
||||
_logger.LogInformation("Duration set from WAV header: {Duration:F2} seconds", Duration);
|
||||
// Feed the same once-only duration to the play session so it can compute the
|
||||
// completion fraction at close. Safe before/after session open — SetDuration
|
||||
// is a no-op when no session is open and idempotent otherwise.
|
||||
_playTracker?.SetDuration(chunkResult.Duration.Value);
|
||||
}
|
||||
|
||||
|
||||
// Start playback as soon as we can
|
||||
if (!_streamingPlaybackStarted && CanStartStreaming)
|
||||
{
|
||||
@@ -316,6 +376,20 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
IsPaused = false;
|
||||
IsLoaded = true; // Track is loaded and ready to play (even if still downloading)
|
||||
ErrorMessage = null;
|
||||
|
||||
// Open the play session exactly once per load, at the moment playback truly
|
||||
// begins (§2.1). The _sessionOpened guard keeps the SeekBeyondBuffer re-stream
|
||||
// — which re-enters this transition with _streamingPlaybackStarted reset —
|
||||
// from opening a second session for the same play. Duration may already be
|
||||
// known from a prior chunk, so re-feed it after opening.
|
||||
if (!_sessionOpened && _currentTrackId is { } trackKey)
|
||||
{
|
||||
_sessionOpened = true;
|
||||
_playTracker?.OnPlaybackStarted(trackKey);
|
||||
if (Duration is { } d)
|
||||
_playTracker?.SetDuration(d);
|
||||
}
|
||||
|
||||
await NotifyStateChanged(); // Immediate notification for critical state change
|
||||
}
|
||||
else
|
||||
@@ -533,6 +607,13 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
/// </summary>
|
||||
private async Task ResetToIdle()
|
||||
{
|
||||
// 0. Close any open play session BEFORE tearing down (§2.1). ResetToIdle is the single funnel
|
||||
// for stop / unload / dispose / track-switch (a new LoadTrackStreaming calls it first), so a
|
||||
// superseded listen is recorded here with its high-water bucket. Close is idempotent — if the
|
||||
// session already closed organically or via the unload beacon, this is a no-op.
|
||||
_playTracker?.Close();
|
||||
_sessionOpened = false;
|
||||
|
||||
// 1. Cancel any ongoing streaming operation and wait for it to exit
|
||||
// before tearing down JS state. Otherwise the loop's pending
|
||||
// ProcessStreamingChunk call can land after StopAsync/UnloadAsync.
|
||||
@@ -630,12 +711,24 @@ public class StreamingAudioPlayerService : AudioPlayerService, IStreamingPlayerS
|
||||
{
|
||||
try
|
||||
{
|
||||
// ResetToIdle closes any open play session, so a dispose mid-play still records the listen.
|
||||
await ResetToIdle();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Disposal must not throw; any failure here is best-effort cleanup.
|
||||
}
|
||||
|
||||
// Detach the page-unload handler so the torn-down circuit is never invoked, then release the
|
||||
// self-reference. Best-effort — the JS side tolerates an absent key.
|
||||
if (_unloadKey is not null && _beacon is not null)
|
||||
{
|
||||
try { await _beacon.UnregisterUnloadAsync(_unloadKey); }
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
_unloadRef?.Dispose();
|
||||
_unloadRef = null;
|
||||
|
||||
await base.DisposeAsync();
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ public static class Startup
|
||||
services.AddScoped<IReleaseDataService, ReleaseClientDataService>();
|
||||
services.AddScoped<ReleaseDetailViewModel>();
|
||||
services.AddScoped<CutDetailViewModel>();
|
||||
services.AddScoped<FramePlayerViewModel>();
|
||||
|
||||
// Home hero stats read surface — same HTTP posture as the track/release clients.
|
||||
services.AddScoped<StatsClient>();
|
||||
@@ -33,6 +34,17 @@ public static class Startup
|
||||
// Waveform visualizer controls — scoped so the eight slider positions persist across navigation
|
||||
// within a session and reset on a fresh page load (see WaveformVisualizerControlState).
|
||||
services.AddScoped<WaveformVisualizerControlState>();
|
||||
|
||||
// Phase 16 anonymous telemetry (client side). BeaconInterop wraps sendBeacon; the play sink and
|
||||
// share tracker fire events through it. The play tracker itself is NOT registered — the player
|
||||
// is not DI-registered, so AudioPlayerProvider constructs the tracker and attaches it. ShareTracker
|
||||
// is scoped so its per-(target,channel) debounce memory lives for the session. AnonIdProvider
|
||||
// (wave 16.3) caches the first-party localStorage listener id; scoped so the cache lives for the
|
||||
// session, warmed when a surface goes interactive (the player provider, the share popover).
|
||||
services.AddScoped<BeaconInterop>();
|
||||
services.AddScoped<IAnonIdProvider, AnonIdProvider>();
|
||||
services.AddScoped<IPlayEventSink, BeaconPlayEventSink>();
|
||||
services.AddScoped<ShareTracker>();
|
||||
}
|
||||
|
||||
public static void ConfigureApiHttpClient(IServiceCollection services, string baseAddress)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
|
||||
namespace DeepDrftPublic.Client.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the ordered track list for a release embed (<c>FramePlayer?ReleaseEntryKey=...</c>). Mirrors
|
||||
/// <see cref="CutDetailViewModel"/>'s release-to-tracks resolution exactly (GetByEntryKey -> release.Id
|
||||
/// -> releaseId-filtered track page sorted by TrackNumber) so an embedded release queues the same
|
||||
/// ordered list the Cut detail page plays. Owns no playback or staging — the page stages the first
|
||||
/// track and arms the queue; this VM only fetches. Scoped; <see cref="Tracks"/> is reset per
|
||||
/// <see cref="Load"/> so a reused instance never bleeds across embeds.
|
||||
/// </summary>
|
||||
public class FramePlayerViewModel
|
||||
{
|
||||
private readonly IReleaseDataService _releaseData;
|
||||
private readonly ITrackDataService _trackData;
|
||||
|
||||
// One page covers the whole release; the API caps PageSize at 100 regardless. Matches CutDetailViewModel.
|
||||
private const int ReleasePageSize = 100;
|
||||
|
||||
public IReadOnlyList<TrackDto> Tracks { get; private set; } = [];
|
||||
|
||||
public FramePlayerViewModel(IReleaseDataService releaseData, ITrackDataService trackData)
|
||||
{
|
||||
_releaseData = releaseData;
|
||||
_trackData = trackData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves <paramref name="releaseEntryKey"/> to its ordered tracks. Leaves <see cref="Tracks"/>
|
||||
/// empty when the release is not found or has no streamable tracks — the caller leaves the bar idle.
|
||||
/// </summary>
|
||||
public async Task Load(string releaseEntryKey)
|
||||
{
|
||||
Tracks = [];
|
||||
|
||||
var releaseResult = await _releaseData.GetByEntryKey(releaseEntryKey);
|
||||
if (releaseResult is not { Success: true, Value: { } release }) return;
|
||||
|
||||
// The release's tracks via the releaseId-filtered page — the exact join the Cut page uses
|
||||
// (internal int FK, not a title string), sorted by the explicit TrackNumber ordinal so the
|
||||
// queue advances in saved order.
|
||||
var trackResult = await _trackData.GetPage(
|
||||
pageNumber: 1,
|
||||
pageSize: ReleasePageSize,
|
||||
sortColumn: "TrackNumber",
|
||||
releaseId: release.Id);
|
||||
if (trackResult is { Success: true, Value: { Items: { } items } })
|
||||
Tracks = items.ToList();
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,8 @@ The proxy forwards public, unauthenticated routes:
|
||||
- `GET api/track/genres` — distinct genres with counts
|
||||
- `GET api/track/random` — random track selection
|
||||
- `GET api/track/meta/by-key/{entryKey}` — metadata lookup by vault entry key
|
||||
- `POST api/event/play` — anonymous play-event telemetry (Phase 16; `EventProxyController`, `[IgnoreAntiforgeryToken]`)
|
||||
- `POST api/event/share` — anonymous share-event telemetry (Phase 16; `EventProxyController`, `[IgnoreAntiforgeryToken]`)
|
||||
|
||||
All actions use `HttpCompletionOption.ResponseHeadersRead` for streaming efficiency. Audio streaming registers the upstream response with `HttpContext.Response.RegisterForDispose()` so the stream is properly cleaned up after the response body is sent.
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
<script src=@Assets["_content/MudBlazor/MudBlazor.min.js"]></script>
|
||||
<script type="module">
|
||||
import('./js/audio/index.js');
|
||||
import('./js/telemetry/beacon.js');
|
||||
import('./js/telemetry/anonid.js');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DeepDrftPublic.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Proxies the anonymous telemetry write endpoints (<c>POST api/event/play</c> / <c>api/event/share</c>)
|
||||
/// to DeepDrftAPI so the WASM client never makes a cross-origin request (Phase 16 §2.2). Mirrors
|
||||
/// <see cref="TrackProxyController"/>'s idiom — the named <c>"DeepDrft.API"</c> client forwards the
|
||||
/// request upstream — but for a POST write: the small JSON body is buffered and relayed verbatim, and
|
||||
/// the upstream status (202 on success, 4xx on a rejected payload, 429 on rate limit) passes back so the
|
||||
/// beacon's fire-and-forget contract is preserved end to end. SSR never posts these — they originate
|
||||
/// from the browser player/share surfaces only.
|
||||
/// </summary>
|
||||
// A sendBeacon POST cannot attach a Blazor antiforgery token, so the telemetry write routes opt out
|
||||
// explicitly. They are anonymous, idempotent-enough fire-and-forget logging — there is no
|
||||
// state-changing user action to protect with CSRF tokens, and the upstream rate-limits by IP.
|
||||
[ApiController]
|
||||
[Route("api/event")]
|
||||
[IgnoreAntiforgeryToken]
|
||||
public class EventProxyController : ControllerBase
|
||||
{
|
||||
private readonly HttpClient _upstream;
|
||||
private readonly ILogger<EventProxyController> _logger;
|
||||
|
||||
public EventProxyController(IHttpClientFactory httpClientFactory, ILogger<EventProxyController> logger)
|
||||
{
|
||||
_upstream = httpClientFactory.CreateClient("DeepDrft.API");
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Proxies a play event upstream. Body is opaque JSON — validated by DeepDrftAPI, not here.</summary>
|
||||
[HttpPost("play")]
|
||||
public Task<ActionResult> ForwardPlay(CancellationToken ct = default) => Forward("api/event/play", ct);
|
||||
|
||||
/// <summary>Proxies a share event upstream.</summary>
|
||||
[HttpPost("share")]
|
||||
public Task<ActionResult> ForwardShare(CancellationToken ct = default) => Forward("api/event/share", ct);
|
||||
|
||||
private async Task<ActionResult> Forward(string upstreamPath, CancellationToken ct)
|
||||
{
|
||||
// Buffer the small JSON body and relay it verbatim. Reading the raw body keeps the proxy
|
||||
// transparent — it does not deserialize or re-shape the payload, just forwards it.
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body, Encoding.UTF8))
|
||||
{
|
||||
body = await reader.ReadToEndAsync(ct);
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, upstreamPath)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, "application/json")
|
||||
};
|
||||
|
||||
// Forward the real client IP so DeepDrftAPI's per-IP rate limiter (Program.cs "events" policy)
|
||||
// partitions on individual listeners rather than the proxy host. Standard XFF chaining: relay
|
||||
// any inbound X-Forwarded-For from an upstream proxy (nginx), then append the connection IP
|
||||
// of the current hop (the browser → public host connection). DeepDrftAPI calls
|
||||
// UseForwardedHeaders() in production, which resolves the leftmost untrusted value in the
|
||||
// chain into Connection.RemoteIpAddress — which the rate limiter then keys on.
|
||||
var clientIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
if (clientIp is not null)
|
||||
{
|
||||
var existing = Request.Headers["X-Forwarded-For"].ToString();
|
||||
var xff = string.IsNullOrEmpty(existing) ? clientIp : $"{existing}, {clientIp}";
|
||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", xff);
|
||||
}
|
||||
|
||||
HttpResponseMessage upstream;
|
||||
try
|
||||
{
|
||||
upstream = await _upstream.SendAsync(request, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Upstream call to DeepDrftAPI {Path} failed", upstreamPath);
|
||||
return StatusCode(502, "Upstream unavailable");
|
||||
}
|
||||
|
||||
// Relay the upstream status as-is. Telemetry is fire-and-forget; the beacon never reads the
|
||||
// body, so there is nothing to relay beyond the code (202 / 400 / 429 / 5xx).
|
||||
using (upstream)
|
||||
{
|
||||
return StatusCode((int)upstream.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Anonymous listener id interop (Phase 16 §3, wave 16.3, D5 Option A). Mints a random first-party GUID
|
||||
* on first visit, stores it in localStorage, and reads it back thereafter — one opaque token per
|
||||
* browser-install-until-cleared. No PII, no fingerprinting, no cross-site use: it is a "this browser,
|
||||
* until you clear it" token the server counts distinctly to estimate unique listeners.
|
||||
*
|
||||
* Degrades safely: if localStorage is unavailable (private mode, blocked, partitioned third-party
|
||||
* iframe) it returns null rather than throwing, and the caller simply sends no anonId. Over-counting is
|
||||
* the known, accepted direction of error (§3).
|
||||
*
|
||||
* Exposed on window.DeepDrftAnonId; imported once in App.razor alongside the audio engine and beacon.
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'deepdrft.anonId';
|
||||
|
||||
// crypto.randomUUID is the standard, secure source. A guarded fallback covers older/insecure-context
|
||||
// browsers where it is absent — still a random opaque token, not a fingerprint.
|
||||
function mint(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// RFC4122-ish fallback from getRandomValues; only reached on browsers lacking randomUUID.
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
const DeepDrftAnonId = {
|
||||
/**
|
||||
* Read the stored anon id, minting and persisting one on first call. Returns null if localStorage
|
||||
* cannot be read or written (private mode / blocked / partitioned) — telemetry then omits the id.
|
||||
* Both the read and the write are guarded independently: a readable-but-unwritable store still mints
|
||||
* a fresh id each call (acceptable over-count) rather than throwing.
|
||||
*/
|
||||
get: (): string | null => {
|
||||
try {
|
||||
const existing = localStorage.getItem(STORAGE_KEY);
|
||||
if (existing) return existing;
|
||||
|
||||
const minted = mint();
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, minted);
|
||||
} catch {
|
||||
// Read worked, write did not — return the minted value anyway; it just won't persist.
|
||||
}
|
||||
return minted;
|
||||
} catch {
|
||||
// localStorage is entirely unavailable — send no anonId.
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DeepDrftAnonId: typeof DeepDrftAnonId;
|
||||
}
|
||||
}
|
||||
|
||||
window.DeepDrftAnonId = DeepDrftAnonId;
|
||||
|
||||
export { DeepDrftAnonId };
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Telemetry beacon interop (Phase 16 §2.2). A thin wrapper over navigator.sendBeacon for fire-and-forget
|
||||
* play/share events, plus a page-unload handler that lets the player close an open play session as the
|
||||
* tab goes away. sendBeacon (not fetch) is the load-bearing choice: it survives page unload, where a
|
||||
* fetch would be cancelled — exactly the tab-close edge case the play metric must still record.
|
||||
*
|
||||
* Exposed on window.DeepDrftBeacon; imported once in App.razor alongside the audio engine.
|
||||
*/
|
||||
|
||||
// .NET interop type — a DotNetObjectReference the unload handler invokes back into.
|
||||
interface DotNetObjectReference {
|
||||
invokeMethodAsync(methodName: string, ...args: unknown[]): Promise<unknown>;
|
||||
invokeMethod(methodName: string, ...args: unknown[]): unknown;
|
||||
}
|
||||
|
||||
// Registered unload listeners. Holding the handler lets us detach on dispose so a torn-down player
|
||||
// circuit does not get called into.
|
||||
type UnloadEntry = { dotNetRef: DotNetObjectReference; methodName: string };
|
||||
const unloadHandlers = new Map<string, UnloadEntry>();
|
||||
|
||||
let unloadWired = false;
|
||||
|
||||
// Fire every registered unload handler synchronously. invokeMethod (sync) — not invokeMethodAsync — is
|
||||
// required here: in pagehide/visibilitychange→hidden the event loop will not pump a microtask before the
|
||||
// page is frozen, so an awaited call would never run. The .NET side does only synchronous beacon work.
|
||||
function fireUnloadHandlers(): void {
|
||||
for (const { dotNetRef, methodName } of unloadHandlers.values()) {
|
||||
try {
|
||||
dotNetRef.invokeMethod(methodName);
|
||||
} catch {
|
||||
// A torn-down circuit or a transient interop failure must never block unload.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wireUnloadOnce(): void {
|
||||
if (unloadWired) return;
|
||||
unloadWired = true;
|
||||
|
||||
// pagehide is the canonical "page is going away" signal (covers tab close, navigation, and the
|
||||
// bfcache freeze). visibilitychange→hidden additionally covers the mobile case where the tab is
|
||||
// backgrounded and may be discarded without a pagehide. Both funnel to the same close path; the
|
||||
// .NET side is idempotent, so a double-fire closes the session at most once.
|
||||
window.addEventListener('pagehide', fireUnloadHandlers);
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') fireUnloadHandlers();
|
||||
});
|
||||
}
|
||||
|
||||
const DeepDrftBeacon = {
|
||||
/**
|
||||
* Queue a fire-and-forget POST of a small JSON body. Returns false if the browser refused to queue
|
||||
* the beacon (e.g. over the per-origin byte budget) — callers ignore it; a dropped telemetry event
|
||||
* is acceptable by design.
|
||||
*/
|
||||
send: (url: string, json: string): boolean => {
|
||||
try {
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
return navigator.sendBeacon(url, blob);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Register a .NET callback to run on page unload (and on visibility→hidden). Keyed so a given player
|
||||
* registers once and can replace/detach cleanly across its lifecycle.
|
||||
*/
|
||||
registerUnload: (key: string, dotNetRef: DotNetObjectReference, methodName: string): void => {
|
||||
wireUnloadOnce();
|
||||
unloadHandlers.set(key, { dotNetRef, methodName });
|
||||
},
|
||||
|
||||
/** Detach a previously-registered unload callback (player dispose). */
|
||||
unregisterUnload: (key: string): void => {
|
||||
unloadHandlers.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
DeepDrftBeacon: typeof DeepDrftBeacon;
|
||||
}
|
||||
}
|
||||
|
||||
window.DeepDrftBeacon = DeepDrftBeacon;
|
||||
|
||||
export { DeepDrftBeacon };
|
||||
@@ -750,3 +750,158 @@ body:has(.waveform-visualizer-control-overlay) {
|
||||
color: var(--mud-palette-text-primary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
QUEUE OVERLAY + LIST (Phase 17 wave 17.2 — docked queue panel)
|
||||
|
||||
The overlay is a direct lift of the visualizer-control modal (Phase 15 §4): a centered MudOverlay
|
||||
whose scrim tint + z-index + body-scroll lock match that idiom exactly. The panel chrome (square
|
||||
corners, lighter-navy ground, thin light border) is the NowPlayingCard treatment (§5). MudOverlay
|
||||
portals out of the component subtree to the body, so these are plain GLOBAL rules — CSS isolation
|
||||
cannot reach portaled content.
|
||||
============================================================================= */
|
||||
|
||||
/* Raise the overlay above the sticky header (100), the fixed player dock (1200), and the minimized
|
||||
FAB (1300) — same stacking decision as the visualizer overlay so the scrim tints the whole viewport. */
|
||||
.deepdrft-queue-overlay {
|
||||
z-index: 1400 !important;
|
||||
}
|
||||
|
||||
/* Mild modal tint from the shared scrim token. The doubled selector (0,2,0) outranks MudBlazor's own
|
||||
.mud-overlay-dark (0,1,0) regardless of stylesheet load order. */
|
||||
.deepdrft-queue-overlay .mud-overlay-scrim.mud-overlay-dark {
|
||||
background-color: rgba(var(--deepdrft-scrim-rgb), var(--deepdrft-modal-scrim-alpha));
|
||||
}
|
||||
|
||||
.deepdrft-queue-overlay .mud-overlay-content {
|
||||
max-height: 90vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Lock body scroll while the queue overlay is open (matches the visualizer overlay). */
|
||||
body:has(.deepdrft-queue-overlay) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The mostly-square panel (§3.2: min(90vw, 520px)). NowPlayingCard chrome: square corners, lighter-navy
|
||||
ground, thin light border. Internal column: fixed header over a scrollable list body. */
|
||||
.deepdrft-queue-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(90vw, 520px);
|
||||
height: min(90vw, 520px);
|
||||
max-height: 90vh;
|
||||
background: var(--deepdrft-panel-ground);
|
||||
border: 1px solid var(--deepdrft-border-light);
|
||||
border-radius: 0;
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deepdrft-queue-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--deepdrft-border-light);
|
||||
}
|
||||
|
||||
/* Mono uppercase eyebrow — the NowPlayingCard .np-label typography, recoloured light (static). */
|
||||
.deepdrft-queue-modal-title {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-white);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.deepdrft-queue-modal-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem 0.5rem 0.75rem;
|
||||
}
|
||||
|
||||
/* ── The list itself (consumed by QueueList in both modes; styled here once). ── */
|
||||
.deepdrft-queue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.deepdrft-queue-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.deepdrft-queue-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: var(--deepdrft-white);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.deepdrft-queue-row:hover {
|
||||
background: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
|
||||
}
|
||||
|
||||
/* Current track: a subtle green wash + left accent, matching the green = active principle. */
|
||||
.deepdrft-queue-row-current {
|
||||
background: color-mix(in srgb, var(--deepdrft-green-accent) 14%, transparent);
|
||||
box-shadow: inset 2px 0 0 0 var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.deepdrft-queue-drag-handle {
|
||||
cursor: grab;
|
||||
opacity: 0.45;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.deepdrft-queue-position {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
min-width: 1.4rem;
|
||||
text-align: right;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Row body grows + truncates; clicking it jumps playback (OQ2). */
|
||||
.deepdrft-queue-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.deepdrft-queue-title {
|
||||
font-size: 0.92rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.deepdrft-queue-artist {
|
||||
font-size: 0.74rem;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.deepdrft-queue-nowplaying,
|
||||
.deepdrft-queue-remove {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Active (open) state for the bar's Queue toggle — a soft green chip behind the glyph, matching the
|
||||
visualizer toggle's on-state idiom. */
|
||||
.deepdrft-queue-toggle-active {
|
||||
background: color-mix(in srgb, var(--deepdrft-green-accent) 22%, transparent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Text.Json;
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests that the Phase 16 wave-16.3 anon id is threaded onto the beacon payloads emitted by
|
||||
/// <see cref="BeaconPlayEventSink"/> and <see cref="ShareTracker"/>, and omitted when the provider has
|
||||
/// no token. Both sinks serialize internally and dispatch through <c>BeaconInterop</c> → the
|
||||
/// <c>DeepDrftBeacon.send(url, json)</c> JS call, so the assertions capture that JSON string off a fake
|
||||
/// JS runtime and inspect the <c>anonId</c> field — the same bytes the browser would POST.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class AnonIdPayloadTests
|
||||
{
|
||||
// Captures the JSON body of the most recent DeepDrftBeacon.send(url, json) invocation. The beacon is
|
||||
// fire-and-forget (returns bool); other interop calls (unload registration) are tolerated and ignored.
|
||||
private sealed class CapturingJsRuntime : IJSRuntime
|
||||
{
|
||||
public string? LastJson { get; private set; }
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||
{
|
||||
if (identifier == "DeepDrftBeacon.send" && args is { Length: 2 } && args[1] is string json)
|
||||
LastJson = json;
|
||||
return ValueTask.FromResult<TValue>(default!);
|
||||
}
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||
=> InvokeAsync<TValue>(identifier, args);
|
||||
}
|
||||
|
||||
private sealed class StubAnonIdProvider : IAnonIdProvider
|
||||
{
|
||||
public StubAnonIdProvider(string? current) => Current = current;
|
||||
public string? Current { get; }
|
||||
public ValueTask EnsureLoadedAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class TestNavigationManager : NavigationManager
|
||||
{
|
||||
public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
|
||||
protected override void NavigateToCore(string uri, bool forceLoad) { }
|
||||
}
|
||||
|
||||
// The sinks serialize with default (PascalCase) property names; the API binds case-insensitively, so
|
||||
// the wire field is "AnonId". Match it case-insensitively here so the test asserts the value, not the
|
||||
// casing convention. Returns (present, value) while the document is still alive.
|
||||
private static (bool Present, string? Value) FindAnonId(string json)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, "anonId", StringComparison.OrdinalIgnoreCase))
|
||||
return (true, prop.Value.GetString());
|
||||
}
|
||||
return (false, null);
|
||||
}
|
||||
|
||||
private static string? ReadAnonId(string json) => FindAnonId(json).Value;
|
||||
|
||||
private static bool HasAnonIdProperty(string json) => FindAnonId(json).Present;
|
||||
|
||||
// A play emitted while the provider holds a token carries that token in the payload.
|
||||
[Test]
|
||||
public void PlaySink_WithAnonId_IncludesItInPayload()
|
||||
{
|
||||
var js = new CapturingJsRuntime();
|
||||
var sink = new BeaconPlayEventSink(
|
||||
new BeaconInterop(js), new StubAnonIdProvider("listener-42"), new TestNavigationManager());
|
||||
|
||||
sink.EmitPlay("track-key", PlayBucket.Complete);
|
||||
|
||||
Assert.That(js.LastJson, Is.Not.Null);
|
||||
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-42"));
|
||||
}
|
||||
|
||||
// A play emitted when the provider has no token (storage unavailable / not warmed) omits anonId
|
||||
// entirely rather than sending anonId:null.
|
||||
[Test]
|
||||
public void PlaySink_WithoutAnonId_OmitsItFromPayload()
|
||||
{
|
||||
var js = new CapturingJsRuntime();
|
||||
var sink = new BeaconPlayEventSink(
|
||||
new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());
|
||||
|
||||
sink.EmitPlay("track-key", PlayBucket.Partial);
|
||||
|
||||
Assert.That(js.LastJson, Is.Not.Null);
|
||||
Assert.That(HasAnonIdProperty(js.LastJson!), Is.False, "null anonId is omitted from the wire payload");
|
||||
}
|
||||
|
||||
// A share recorded while the provider holds a token carries it in the payload.
|
||||
[Test]
|
||||
public void ShareTracker_WithAnonId_IncludesItInPayload()
|
||||
{
|
||||
var js = new CapturingJsRuntime();
|
||||
var tracker = new ShareTracker(
|
||||
new BeaconInterop(js), new StubAnonIdProvider("listener-7"), new TestNavigationManager());
|
||||
|
||||
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
|
||||
|
||||
Assert.That(js.LastJson, Is.Not.Null);
|
||||
Assert.That(ReadAnonId(js.LastJson!), Is.EqualTo("listener-7"));
|
||||
}
|
||||
|
||||
// A share recorded with no token omits anonId from the payload.
|
||||
[Test]
|
||||
public void ShareTracker_WithoutAnonId_OmitsItFromPayload()
|
||||
{
|
||||
var js = new CapturingJsRuntime();
|
||||
var tracker = new ShareTracker(
|
||||
new BeaconInterop(js), new StubAnonIdProvider(null), new TestNavigationManager());
|
||||
|
||||
tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link);
|
||||
|
||||
Assert.That(js.LastJson, Is.Not.Null);
|
||||
Assert.That(HasAnonIdProperty(js.LastJson!), Is.False);
|
||||
}
|
||||
|
||||
// A JS runtime that throws on every call — models localStorage interop being unavailable (private
|
||||
// mode, blocked storage, or the module not yet imported during prerender).
|
||||
private sealed class ThrowingJsRuntime : IJSRuntime
|
||||
{
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||
=> throw new InvalidOperationException("interop unavailable");
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||
=> throw new InvalidOperationException("interop unavailable");
|
||||
}
|
||||
|
||||
// The real AnonIdProvider degrades to null (no throw) when the localStorage interop is unavailable —
|
||||
// the acceptance-criterion "degrades safely if localStorage is unavailable". EnsureLoadedAsync
|
||||
// swallows the interop failure and leaves Current null.
|
||||
[Test]
|
||||
public async Task AnonIdProvider_WhenInteropUnavailable_DegradesToNullWithoutThrowing()
|
||||
{
|
||||
var provider = new AnonIdProvider(new ThrowingJsRuntime());
|
||||
|
||||
await provider.EnsureLoadedAsync(); // must not throw
|
||||
|
||||
Assert.That(provider.Current, Is.Null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Storage-layer tests for the Phase 16 wave-16.3 anon-id layer (<see cref="EventRepository"/>): the
|
||||
/// anon id persists to the <c>anon_id</c> column on play and share writes (and a null persists null),
|
||||
/// and the all-time distinct-listener aggregation (§3 / D3) is correct site-wide, per-track, and
|
||||
/// per-release (derived), with null anon ids excluded from every distinct count. Runs on the EF
|
||||
/// in-memory provider like <see cref="PlayEventQueryTests"/>; the transaction-ignored warning is
|
||||
/// suppressed because in-memory has no real transactions (the play write wraps append + bump in one).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class AnonIdQueryTests
|
||||
{
|
||||
private DeepDrftContext _context = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options;
|
||||
_context = new DeepDrftContext(options);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _context.Dispose();
|
||||
|
||||
private EventRepository CreateRepository() => new(_context);
|
||||
|
||||
private async Task<(ReleaseEntity Release, TrackEntity Track)> SeedTrackAsync(string trackKey)
|
||||
{
|
||||
var release = new ReleaseEntity
|
||||
{
|
||||
EntryKey = Guid.NewGuid().ToString("N"),
|
||||
Title = "R",
|
||||
Artist = "A",
|
||||
Medium = ReleaseMedium.Cut,
|
||||
};
|
||||
var track = new TrackEntity { EntryKey = trackKey, TrackName = "T", Release = release };
|
||||
_context.Releases.Add(release);
|
||||
_context.Tracks.Add(track);
|
||||
await _context.SaveChangesAsync();
|
||||
return (release, track);
|
||||
}
|
||||
|
||||
// --- Persistence of the anon id ---
|
||||
|
||||
// A play carrying an anon id writes it to the column.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_WithAnonId_PersistsIt()
|
||||
{
|
||||
await SeedTrackAsync("track-1");
|
||||
|
||||
await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, anonId: "anon-abc");
|
||||
|
||||
var ev = await _context.PlayEvents.SingleAsync();
|
||||
Assert.That(ev.AnonId, Is.EqualTo("anon-abc"));
|
||||
}
|
||||
|
||||
// A play with no anon id (the provider returned null) persists a null column — both paths covered.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_WithoutAnonId_PersistsNull()
|
||||
{
|
||||
await SeedTrackAsync("track-1");
|
||||
|
||||
await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, anonId: null);
|
||||
|
||||
var ev = await _context.PlayEvents.SingleAsync();
|
||||
Assert.That(ev.AnonId, Is.Null);
|
||||
}
|
||||
|
||||
// A share carrying an anon id writes it to the column; a null share persists null.
|
||||
[Test]
|
||||
public async Task RecordShareAsync_PersistsAnonId()
|
||||
{
|
||||
var repo = CreateRepository();
|
||||
await repo.RecordShareAsync(ShareTargetType.Track, "k", ShareChannel.Link, anonId: "anon-xyz");
|
||||
await repo.RecordShareAsync(ShareTargetType.Track, "k", ShareChannel.Embed, anonId: null);
|
||||
|
||||
var withId = await _context.ShareEvents.SingleAsync(e => e.Channel == ShareChannel.Link);
|
||||
var without = await _context.ShareEvents.SingleAsync(e => e.Channel == ShareChannel.Embed);
|
||||
Assert.That(withId.AnonId, Is.EqualTo("anon-xyz"));
|
||||
Assert.That(without.AnonId, Is.Null);
|
||||
}
|
||||
|
||||
// --- Site-wide distinct listeners (§3 / D3, all-time) ---
|
||||
|
||||
// Distinct anon ids are counted once each; a listener who plays many times counts once.
|
||||
[Test]
|
||||
public async Task CountDistinctListeners_CountsEachAnonOnce()
|
||||
{
|
||||
await SeedTrackAsync("track-1");
|
||||
var repo = CreateRepository();
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-1");
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Partial, "anon-1"); // same listener, replay
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, "anon-2");
|
||||
|
||||
Assert.That(await repo.CountDistinctListenersAsync(), Is.EqualTo(2));
|
||||
}
|
||||
|
||||
// Null anon ids are excluded from the distinct count — an anonId-less play is not a known listener.
|
||||
[Test]
|
||||
public async Task CountDistinctListeners_ExcludesNullAnonIds()
|
||||
{
|
||||
await SeedTrackAsync("track-1");
|
||||
var repo = CreateRepository();
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-1");
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
|
||||
|
||||
Assert.That(await repo.CountDistinctListenersAsync(), Is.EqualTo(1),
|
||||
"null anonIds must not inflate the listener count");
|
||||
}
|
||||
|
||||
// With no anon ids at all, the count is zero (not an error).
|
||||
[Test]
|
||||
public async Task CountDistinctListeners_AllNull_IsZero()
|
||||
{
|
||||
await SeedTrackAsync("track-1");
|
||||
await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, null);
|
||||
|
||||
Assert.That(await CreateRepository().CountDistinctListenersAsync(), Is.EqualTo(0));
|
||||
}
|
||||
|
||||
// --- Per-track distinct listeners ---
|
||||
|
||||
// The per-track count scopes to the track key and counts distinct non-null anon ids.
|
||||
[Test]
|
||||
public async Task CountDistinctListenersForTrack_ScopesToTrack()
|
||||
{
|
||||
await SeedTrackAsync("track-1");
|
||||
await SeedTrackAsync("track-2");
|
||||
var repo = CreateRepository();
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-1");
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, "anon-2");
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null); // excluded
|
||||
await repo.RecordPlayAsync("track-2", PlayBucket.Complete, "anon-3");
|
||||
|
||||
Assert.That(await repo.CountDistinctListenersForTrackAsync("track-1"), Is.EqualTo(2));
|
||||
Assert.That(await repo.CountDistinctListenersForTrackAsync("track-2"), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
// --- Per-release distinct listeners (derived, D4) ---
|
||||
|
||||
// A release's listener count is the distinct anon ids across all its tracks: a listener who heard two
|
||||
// tracks of the release counts once (union, not a per-track sum), and null anon ids are excluded.
|
||||
[Test]
|
||||
public async Task CountDistinctListenersForRelease_DistinctAcrossTracks()
|
||||
{
|
||||
var release = new ReleaseEntity
|
||||
{
|
||||
EntryKey = Guid.NewGuid().ToString("N"), Title = "R", Artist = "A", Medium = ReleaseMedium.Cut,
|
||||
};
|
||||
var t1 = new TrackEntity { EntryKey = "t1", TrackName = "T1", Release = release };
|
||||
var t2 = new TrackEntity { EntryKey = "t2", TrackName = "T2", Release = release };
|
||||
_context.Releases.Add(release);
|
||||
_context.Tracks.AddRange(t1, t2);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var repo = CreateRepository();
|
||||
await repo.RecordPlayAsync("t1", PlayBucket.Complete, "anon-1");
|
||||
await repo.RecordPlayAsync("t2", PlayBucket.Complete, "anon-1"); // same listener, second track
|
||||
await repo.RecordPlayAsync("t2", PlayBucket.Complete, "anon-2");
|
||||
await repo.RecordPlayAsync("t1", PlayBucket.Complete, null); // excluded
|
||||
|
||||
Assert.That(await repo.CountDistinctListenersForReleaseAsync(release.Id), Is.EqualTo(2),
|
||||
"anon-1 heard two tracks but is one distinct listener of the release");
|
||||
}
|
||||
|
||||
// A play of a track in another release does not bleed into this release's listener count.
|
||||
[Test]
|
||||
public async Task CountDistinctListenersForRelease_ExcludesOtherReleases()
|
||||
{
|
||||
var (releaseA, _) = await SeedTrackAsync("a-track");
|
||||
var (releaseB, _) = await SeedTrackAsync("b-track");
|
||||
var repo = CreateRepository();
|
||||
await repo.RecordPlayAsync("a-track", PlayBucket.Complete, "anon-1");
|
||||
await repo.RecordPlayAsync("b-track", PlayBucket.Complete, "anon-2");
|
||||
|
||||
Assert.That(await repo.CountDistinctListenersForReleaseAsync(releaseA.Id), Is.EqualTo(1));
|
||||
Assert.That(await repo.CountDistinctListenersForReleaseAsync(releaseB.Id), Is.EqualTo(1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using DeepDrftPublic.Client.Helpers;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the share-popover embed snippet (<see cref="EmbedSnippetBuilder"/>). The builder is
|
||||
/// the mode-aware half of SharePopover: track mode targets FramePlayer's TrackEntryKey param, release
|
||||
/// mode targets its ReleaseEntryKey param. The iframe chrome (dimensions, autoplay) must be identical
|
||||
/// across both. Pure string composition, tested directly without rendering the component.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class EmbedSnippetBuilderTests
|
||||
{
|
||||
private const string BaseUri = "https://deepdrft.example/";
|
||||
|
||||
[Test]
|
||||
public void ForTrack_EmitsTrackEntryKeySrc()
|
||||
{
|
||||
var snippet = EmbedSnippetBuilder.ForTrack(BaseUri, "abc123");
|
||||
|
||||
Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?TrackEntryKey=abc123"""));
|
||||
Assert.That(snippet, Does.Not.Contain("ReleaseEntryKey"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForRelease_EmitsReleaseEntryKeySrc()
|
||||
{
|
||||
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
|
||||
|
||||
Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?ReleaseEntryKey=rel-xyz"""));
|
||||
Assert.That(snippet, Does.Not.Contain("TrackEntryKey"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BothModes_ShareIdenticalIframeChrome()
|
||||
{
|
||||
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
|
||||
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
foreach (var snippet in new[] { track, release })
|
||||
{
|
||||
Assert.That(snippet, Does.StartWith("<iframe "));
|
||||
Assert.That(snippet, Does.Contain(@"width=""656"""));
|
||||
Assert.That(snippet, Does.Contain(@"height=""196"""));
|
||||
Assert.That(snippet, Does.Contain(@"frameborder=""0"""));
|
||||
Assert.That(snippet, Does.Contain(@"style=""border-radius:8px;"""));
|
||||
Assert.That(snippet, Does.Contain(@"allow=""autoplay"""));
|
||||
Assert.That(snippet, Does.EndWith("></iframe>"));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using DeepDrftPublic.Client.ViewModels;
|
||||
using Models.Common;
|
||||
using NetBlocks.Models;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the release-embed track resolution (<see cref="FramePlayerViewModel"/>). The VM is
|
||||
/// the data half of FramePlayer's release path: resolve a release EntryKey to its ordered track list,
|
||||
/// which the page then stages (track 0) and arms into the queue. It is pure async logic over the two
|
||||
/// data-service seams, so it is exercised here against recording fakes — no browser, no JS, no HTTP.
|
||||
/// Coverage: ordered non-empty resolution, single-track release, release-not-found, and a release
|
||||
/// that resolves to no tracks (the "leave the bar idle, don't throw" path).
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class FramePlayerReleaseResolutionTests
|
||||
{
|
||||
private FakeReleaseData _releaseData = null!;
|
||||
private FakeTrackData _trackData = null!;
|
||||
private FramePlayerViewModel _vm = null!;
|
||||
|
||||
private const string ReleaseKey = "release-key-1";
|
||||
private const long ReleaseId = 42;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_releaseData = new FakeReleaseData();
|
||||
_trackData = new FakeTrackData();
|
||||
_vm = new FramePlayerViewModel(_releaseData, _trackData);
|
||||
}
|
||||
|
||||
private static ReleaseDto Release() => new() { Id = ReleaseId, EntryKey = ReleaseKey, Title = "Album" };
|
||||
|
||||
private static List<TrackDto> Tracks(int count) =>
|
||||
Enumerable.Range(1, count)
|
||||
.Select(i => new TrackDto { EntryKey = $"track-{i}", TrackName = $"Track {i}", TrackNumber = i })
|
||||
.ToList();
|
||||
|
||||
[Test]
|
||||
public async Task Load_ResolvesOrderedNonEmptyTrackList_AndQueriesByResolvedReleaseId()
|
||||
{
|
||||
_releaseData.Release = Release();
|
||||
_trackData.Page = Tracks(3);
|
||||
|
||||
await _vm.Load(ReleaseKey);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_vm.Tracks.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||
// The track page must be narrowed by the resolved release.Id (the int FK join), sorted by
|
||||
// the explicit TrackNumber ordinal — the same resolution the Cut detail page uses.
|
||||
Assert.That(_trackData.LastReleaseId, Is.EqualTo(ReleaseId));
|
||||
Assert.That(_trackData.LastSortColumn, Is.EqualTo("TrackNumber"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Load_WithSingleTrackRelease_ResolvesAOneItemList()
|
||||
{
|
||||
_releaseData.Release = Release();
|
||||
_trackData.Page = Tracks(1);
|
||||
|
||||
await _vm.Load(ReleaseKey);
|
||||
|
||||
Assert.That(_vm.Tracks, Has.Count.EqualTo(1));
|
||||
Assert.That(_vm.Tracks[0].EntryKey, Is.EqualTo("track-1"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Load_WhenReleaseNotFound_LeavesTracksEmptyAndDoesNotQueryTracks()
|
||||
{
|
||||
_releaseData.Release = null; // GetByEntryKey returns a fail result.
|
||||
|
||||
await _vm.Load(ReleaseKey);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_vm.Tracks, Is.Empty);
|
||||
Assert.That(_trackData.WasQueried, Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Load_WhenReleaseResolvesToNoTracks_LeavesTracksEmptyWithoutThrowing()
|
||||
{
|
||||
_releaseData.Release = Release();
|
||||
_trackData.Page = new List<TrackDto>(); // empty page
|
||||
|
||||
await _vm.Load(ReleaseKey);
|
||||
|
||||
Assert.That(_vm.Tracks, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Load_ResetsTracksFromAPriorLoad()
|
||||
{
|
||||
_releaseData.Release = Release();
|
||||
_trackData.Page = Tracks(3);
|
||||
await _vm.Load(ReleaseKey);
|
||||
Assert.That(_vm.Tracks, Has.Count.EqualTo(3));
|
||||
|
||||
// A second load whose release is not found must not retain the prior album's tracks.
|
||||
_releaseData.Release = null;
|
||||
await _vm.Load("another-key");
|
||||
|
||||
Assert.That(_vm.Tracks, Is.Empty);
|
||||
}
|
||||
|
||||
private sealed class FakeReleaseData : IReleaseDataService
|
||||
{
|
||||
public ReleaseDto? Release { get; set; }
|
||||
|
||||
public Task<ApiResult<ReleaseDto>> GetByEntryKey(string entryKey)
|
||||
=> Task.FromResult(Release is null
|
||||
? ApiResult<ReleaseDto>.CreateFailResult("not found")
|
||||
: ApiResult<ReleaseDto>.CreatePassResult(Release));
|
||||
|
||||
public Task<ApiResult<PagedResult<ReleaseDto>>> GetPaged(
|
||||
string? medium, int page, int pageSize, string? sortColumn = null,
|
||||
bool sortDescending = false, string? search = null, string? genre = null)
|
||||
=> throw new NotSupportedException("FramePlayerViewModel does not page releases.");
|
||||
}
|
||||
|
||||
private sealed class FakeTrackData : ITrackDataService
|
||||
{
|
||||
public List<TrackDto> Page { get; set; } = new();
|
||||
public bool WasQueried { get; private set; }
|
||||
public long? LastReleaseId { get; private set; }
|
||||
public string? LastSortColumn { get; private set; }
|
||||
|
||||
public Task<ApiResult<PagedResult<TrackDto>>> GetPage(
|
||||
int pageNumber, int pageSize, string? sortColumn = null, bool sortDescending = false,
|
||||
string? searchText = null, string? album = null, string? genre = null, long? releaseId = null)
|
||||
{
|
||||
WasQueried = true;
|
||||
LastReleaseId = releaseId;
|
||||
LastSortColumn = sortColumn;
|
||||
var paged = new PagedResult<TrackDto>
|
||||
{
|
||||
Items = Page,
|
||||
TotalCount = Page.Count,
|
||||
Page = pageNumber,
|
||||
PageSize = pageSize,
|
||||
};
|
||||
return Task.FromResult(ApiResult<PagedResult<TrackDto>>.CreatePassResult(paged));
|
||||
}
|
||||
|
||||
// Inert remainder — FramePlayerViewModel only calls GetPage.
|
||||
public Task<ApiResult<List<ReleaseDto>>> GetAlbums() => throw new NotSupportedException();
|
||||
public Task<ApiResult<List<GenreSummaryDto>>> GetGenres() => throw new NotSupportedException();
|
||||
public Task<ApiResult<TrackDto>> GetTrack(string trackId) => throw new NotSupportedException();
|
||||
public Task<ApiResult<WaveformProfileDto?>> GetTrackWaveform(string trackEntryKey) => throw new NotSupportedException();
|
||||
public Task<ApiResult<TrackDto?>> GetRandomTrack() => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Storage-layer tests for the Phase 16 telemetry writes (<see cref="EventRepository"/>): server-side
|
||||
/// release resolution (§2.3 / D4), the incremental play-counter bump in the same write (D6), the
|
||||
/// derived-release-total shape (a release's plays are the sum of its tracks'), and the share append.
|
||||
/// Runs on the EF in-memory provider like <see cref="HomeStatsQueryTests"/>. In-memory does not support
|
||||
/// real transactions, so the transaction-ignored warning is suppressed — the production Postgres path
|
||||
/// wraps the append + bump in one transaction, which the warning would otherwise turn into an error here.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class PlayEventQueryTests
|
||||
{
|
||||
private DeepDrftContext _context = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
|
||||
.Options;
|
||||
_context = new DeepDrftContext(options);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _context.Dispose();
|
||||
|
||||
private EventRepository CreateRepository() => new(_context);
|
||||
|
||||
private async Task<(ReleaseEntity Release, TrackEntity Track)> SeedTrackAsync(string trackKey)
|
||||
{
|
||||
var release = new ReleaseEntity
|
||||
{
|
||||
EntryKey = Guid.NewGuid().ToString("N"),
|
||||
Title = "R",
|
||||
Artist = "A",
|
||||
Medium = ReleaseMedium.Cut,
|
||||
};
|
||||
var track = new TrackEntity { EntryKey = trackKey, TrackName = "T", Release = release };
|
||||
_context.Releases.Add(release);
|
||||
_context.Tracks.Add(track);
|
||||
await _context.SaveChangesAsync();
|
||||
return (release, track);
|
||||
}
|
||||
|
||||
// A play that reaches the repository (the floor is the tracker's job) writes exactly one play_event
|
||||
// row with the release id resolved server-side, and bumps the matching bucket on the track's counter.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_ResolvesReleaseAndBumpsCounter()
|
||||
{
|
||||
var (release, track) = await SeedTrackAsync("track-1");
|
||||
|
||||
await CreateRepository().RecordPlayAsync("track-1", PlayBucket.Complete, anonId: null);
|
||||
|
||||
var ev = await _context.PlayEvents.SingleAsync();
|
||||
Assert.That(ev.TrackEntryKey, Is.EqualTo("track-1"));
|
||||
Assert.That(ev.ReleaseId, Is.EqualTo(release.Id), "release resolved server-side from the track key");
|
||||
Assert.That(ev.Bucket, Is.EqualTo(PlayBucket.Complete));
|
||||
Assert.That(ev.AnonId, Is.Null, "no anonId is written in wave 16.1");
|
||||
|
||||
var counter = await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id);
|
||||
Assert.That(counter.CompleteCount, Is.EqualTo(1));
|
||||
Assert.That(counter.TotalPlays, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
// Each bucket bumps its own column; total plays is the sum across buckets.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_BucketsAccumulateIndependently()
|
||||
{
|
||||
var (_, track) = await SeedTrackAsync("track-1");
|
||||
var repo = CreateRepository();
|
||||
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Partial, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
|
||||
|
||||
var counter = await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id);
|
||||
Assert.That(counter.PartialCount, Is.EqualTo(1));
|
||||
Assert.That(counter.SampledCount, Is.EqualTo(2));
|
||||
Assert.That(counter.CompleteCount, Is.EqualTo(1));
|
||||
Assert.That(counter.TotalPlays, Is.EqualTo(4));
|
||||
Assert.That(await _context.PlayEvents.CountAsync(), Is.EqualTo(4));
|
||||
}
|
||||
|
||||
// Release totals are derived (D4): summing the counters of the release's tracks gives release plays;
|
||||
// there is no separate release-counter row.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_ReleaseTotalIsSumOfTrackCounters()
|
||||
{
|
||||
var release = new ReleaseEntity
|
||||
{
|
||||
EntryKey = Guid.NewGuid().ToString("N"), Title = "R", Artist = "A", Medium = ReleaseMedium.Cut,
|
||||
};
|
||||
var t1 = new TrackEntity { EntryKey = "t1", TrackName = "T1", Release = release };
|
||||
var t2 = new TrackEntity { EntryKey = "t2", TrackName = "T2", Release = release };
|
||||
_context.Releases.Add(release);
|
||||
_context.Tracks.AddRange(t1, t2);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var repo = CreateRepository();
|
||||
await repo.RecordPlayAsync("t1", PlayBucket.Complete, null);
|
||||
await repo.RecordPlayAsync("t1", PlayBucket.Partial, null);
|
||||
await repo.RecordPlayAsync("t2", PlayBucket.Sampled, null);
|
||||
|
||||
var releaseTotal = await _context.PlayCounters
|
||||
.Where(c => _context.Tracks.Any(t => t.Id == c.TrackId && t.ReleaseId == release.Id))
|
||||
.SumAsync(c => c.PartialCount + c.SampledCount + c.CompleteCount);
|
||||
|
||||
Assert.That(releaseTotal, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
// A loose track (no release) logs the event with a null release id and still bumps its own counter.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_LooseTrack_NullReleaseStillCounts()
|
||||
{
|
||||
var track = new TrackEntity { EntryKey = "loose", TrackName = "T" };
|
||||
_context.Tracks.Add(track);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await CreateRepository().RecordPlayAsync("loose", PlayBucket.Sampled, null);
|
||||
|
||||
var ev = await _context.PlayEvents.SingleAsync();
|
||||
Assert.That(ev.ReleaseId, Is.Null);
|
||||
Assert.That((await _context.PlayCounters.SingleAsync(c => c.TrackId == track.Id)).SampledCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
// A play of an unknown/removed track key still logs (null release, no counter bump) rather than failing.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_UnknownTrackKey_LogsEventWithoutCounter()
|
||||
{
|
||||
await CreateRepository().RecordPlayAsync("does-not-exist", PlayBucket.Partial, null);
|
||||
|
||||
var ev = await _context.PlayEvents.SingleAsync();
|
||||
Assert.That(ev.ReleaseId, Is.Null);
|
||||
Assert.That(await _context.PlayCounters.AnyAsync(), Is.False, "no track to roll up against");
|
||||
}
|
||||
|
||||
// A soft-deleted track resolves to null (the !IsDeleted guard) — the play still logs, no counter bump.
|
||||
[Test]
|
||||
public async Task RecordPlayAsync_SoftDeletedTrack_DoesNotResolveRelease()
|
||||
{
|
||||
var (_, track) = await SeedTrackAsync("gone");
|
||||
track.IsDeleted = true;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
await CreateRepository().RecordPlayAsync("gone", PlayBucket.Complete, null);
|
||||
|
||||
var ev = await _context.PlayEvents.SingleAsync();
|
||||
Assert.That(ev.ReleaseId, Is.Null);
|
||||
Assert.That(await _context.PlayCounters.AnyAsync(c => c.TrackId == track.Id), Is.False);
|
||||
}
|
||||
|
||||
// --- Site-wide total plays (§5 — the home Plays card's primary figure) ---
|
||||
|
||||
// An empty counter table sums to zero rather than throwing — the card's reading until the
|
||||
// telemetry migration is applied and the first play lands.
|
||||
[Test]
|
||||
public async Task CountTotalPlaysAsync_NoCounters_IsZero()
|
||||
{
|
||||
Assert.That(await CreateRepository().CountTotalPlaysAsync(), Is.EqualTo(0L));
|
||||
}
|
||||
|
||||
// Total plays sums all three bucket columns of a single track's counter.
|
||||
[Test]
|
||||
public async Task CountTotalPlaysAsync_SumsAllBucketsOfOneCounter()
|
||||
{
|
||||
await SeedTrackAsync("track-1");
|
||||
var repo = CreateRepository();
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Partial, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Sampled, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
|
||||
|
||||
Assert.That(await repo.CountTotalPlaysAsync(), Is.EqualTo(4L));
|
||||
}
|
||||
|
||||
// Total plays is site-wide: it sums across every track's counter, not one track's.
|
||||
[Test]
|
||||
public async Task CountTotalPlaysAsync_SumsAcrossAllTracks()
|
||||
{
|
||||
await SeedTrackAsync("track-1");
|
||||
await SeedTrackAsync("track-2");
|
||||
var repo = CreateRepository();
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Complete, null);
|
||||
await repo.RecordPlayAsync("track-1", PlayBucket.Partial, null);
|
||||
await repo.RecordPlayAsync("track-2", PlayBucket.Sampled, null);
|
||||
|
||||
Assert.That(await repo.CountTotalPlaysAsync(), Is.EqualTo(3L),
|
||||
"site-wide total spans every track's counter");
|
||||
}
|
||||
|
||||
// A share append writes one row with the target, channel, and a null anonId.
|
||||
[Test]
|
||||
public async Task RecordShareAsync_AppendsRow()
|
||||
{
|
||||
await CreateRepository().RecordShareAsync(ShareTargetType.Release, "rel-key", ShareChannel.Embed, anonId: null);
|
||||
|
||||
var ev = await _context.ShareEvents.SingleAsync();
|
||||
Assert.That(ev.TargetType, Is.EqualTo(ShareTargetType.Release));
|
||||
Assert.That(ev.TargetKey, Is.EqualTo("rel-key"));
|
||||
Assert.That(ev.Channel, Is.EqualTo(ShareChannel.Embed));
|
||||
Assert.That(ev.AnonId, Is.Null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the Phase 16 play-session tracker (<see cref="PlayTracker"/>): the engagement-floor
|
||||
/// gate (§1d / D2 — ≥3s OR ≥5% of duration, whichever smaller) and the three-bucket completion
|
||||
/// classification (§1a / D1 — partial <30%, sampled 30–80%, complete >80%), exercised behind a
|
||||
/// fake sink so the logic is tested with no player or JS interop — the seam the spec calls out as
|
||||
/// testable. Also covers the high-water (seek-backward) and idempotent-close invariants.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class PlayTrackerTests
|
||||
{
|
||||
// Captures emitted plays so assertions read the (key, bucket) the tracker classified.
|
||||
private sealed class FakeSink : IPlayEventSink
|
||||
{
|
||||
public List<(string Key, PlayBucket Bucket)> Emitted { get; } = new();
|
||||
public void EmitPlay(string trackEntryKey, PlayBucket bucket) => Emitted.Add((trackEntryKey, bucket));
|
||||
}
|
||||
|
||||
private FakeSink _sink = null!;
|
||||
private PlayTracker _tracker = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_sink = new FakeSink();
|
||||
_tracker = new PlayTracker(_sink);
|
||||
}
|
||||
|
||||
// Drive a full session: open, set duration, advance to a high-water position, close.
|
||||
private void PlaySession(string key, double duration, double highWater)
|
||||
{
|
||||
_tracker.OnPlaybackStarted(key);
|
||||
_tracker.SetDuration(duration);
|
||||
_tracker.OnProgress(highWater);
|
||||
_tracker.Close();
|
||||
}
|
||||
|
||||
// --- Engagement floor (§1d / D2) ---
|
||||
|
||||
// A long track floors on the 3-second wall (3s < 5% of 200s = 10s): under 3s sends nothing.
|
||||
[Test]
|
||||
public void Close_LongTrackUnderThreeSeconds_SendsNothing()
|
||||
{
|
||||
PlaySession("t", duration: 200, highWater: 2.5);
|
||||
Assert.That(_sink.Emitted, Is.Empty);
|
||||
}
|
||||
|
||||
// The same long track at exactly the 3-second floor crosses it and records a (partial) play.
|
||||
[Test]
|
||||
public void Close_LongTrackAtThreeSecondFloor_RecordsPlay()
|
||||
{
|
||||
PlaySession("t", duration: 200, highWater: 3.0);
|
||||
Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
|
||||
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
|
||||
}
|
||||
|
||||
// A short clip floors on the percentage (5% of 40s = 2s < 3s): 1.5s is below 2s → nothing.
|
||||
[Test]
|
||||
public void Close_ShortClipUnderFivePercent_SendsNothing()
|
||||
{
|
||||
PlaySession("t", duration: 40, highWater: 1.5);
|
||||
Assert.That(_sink.Emitted, Is.Empty);
|
||||
}
|
||||
|
||||
// The same short clip at the 5%-of-duration floor (2s) crosses it and records.
|
||||
[Test]
|
||||
public void Close_ShortClipAtFivePercentFloor_RecordsPlay()
|
||||
{
|
||||
PlaySession("t", duration: 40, highWater: 2.0);
|
||||
Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
// --- Bucket classification (§1a / D1) ---
|
||||
|
||||
// [floor, 30%) → partial. 60/200 = 30% is the boundary, so 59.9/200 (just under) is partial.
|
||||
[Test]
|
||||
public void Close_UnderThirtyPercent_ClassifiesPartial()
|
||||
{
|
||||
PlaySession("t", duration: 200, highWater: 50); // 25%
|
||||
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
|
||||
}
|
||||
|
||||
// Exactly 30% is the start of the sampled band [30%, 80%].
|
||||
[Test]
|
||||
public void Close_AtThirtyPercent_ClassifiesSampled()
|
||||
{
|
||||
PlaySession("t", duration: 200, highWater: 60); // 30%
|
||||
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
|
||||
}
|
||||
|
||||
// Mid-band is sampled.
|
||||
[Test]
|
||||
public void Close_MidBand_ClassifiesSampled()
|
||||
{
|
||||
PlaySession("t", duration: 200, highWater: 120); // 60%
|
||||
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
|
||||
}
|
||||
|
||||
// Exactly 80% is the inclusive top of the sampled band — still sampled, not complete.
|
||||
[Test]
|
||||
public void Close_AtEightyPercent_ClassifiesSampled()
|
||||
{
|
||||
PlaySession("t", duration: 200, highWater: 160); // 80%
|
||||
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Sampled));
|
||||
}
|
||||
|
||||
// Past 80% is complete.
|
||||
[Test]
|
||||
public void Close_OverEightyPercent_ClassifiesComplete()
|
||||
{
|
||||
PlaySession("t", duration: 200, highWater: 190); // 95%
|
||||
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
|
||||
}
|
||||
|
||||
// A full listen classifies complete, and the high-water is clamped (never over 100%).
|
||||
[Test]
|
||||
public void Close_FullListen_ClassifiesComplete()
|
||||
{
|
||||
PlaySession("t", duration: 200, highWater: 200);
|
||||
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
|
||||
}
|
||||
|
||||
// --- High-water invariant (§1d: seeks never lower the mark) ---
|
||||
|
||||
// Seeking backward after reaching the end still classifies complete — the max position wins.
|
||||
[Test]
|
||||
public void Close_SeekBackwardAfterEnd_StaysComplete()
|
||||
{
|
||||
_tracker.OnPlaybackStarted("t");
|
||||
_tracker.SetDuration(200);
|
||||
_tracker.OnProgress(190); // reached 95%
|
||||
_tracker.OnProgress(20); // seek back to 10% — must NOT lower the high-water
|
||||
_tracker.Close();
|
||||
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Complete));
|
||||
}
|
||||
|
||||
// --- Session lifecycle ---
|
||||
|
||||
// Carries the track key the session opened with through to the emitted event.
|
||||
[Test]
|
||||
public void Close_RecordsTheOpenedTrackKey()
|
||||
{
|
||||
PlaySession("the-entry-key", duration: 100, highWater: 90);
|
||||
Assert.That(_sink.Emitted[0].Key, Is.EqualTo("the-entry-key"));
|
||||
}
|
||||
|
||||
// Close is idempotent — a second close (e.g. organic end after the unload beacon already fired) emits nothing further.
|
||||
[Test]
|
||||
public void Close_CalledTwice_EmitsOnce()
|
||||
{
|
||||
PlaySession("t", duration: 100, highWater: 90);
|
||||
_tracker.Close();
|
||||
Assert.That(_sink.Emitted, Has.Count.EqualTo(1));
|
||||
}
|
||||
|
||||
// A session with no duration ever set (header never parsed) has no fraction to classify → nothing.
|
||||
[Test]
|
||||
public void Close_WithoutDuration_SendsNothing()
|
||||
{
|
||||
_tracker.OnPlaybackStarted("t");
|
||||
_tracker.OnProgress(30);
|
||||
_tracker.Close();
|
||||
Assert.That(_sink.Emitted, Is.Empty);
|
||||
}
|
||||
|
||||
// Progress before any session opens is ignored (no open session to advance), so a later open+close
|
||||
// starts the high-water from zero.
|
||||
[Test]
|
||||
public void OnProgress_BeforeOpen_IsIgnored()
|
||||
{
|
||||
_tracker.OnProgress(150);
|
||||
PlaySession("t", duration: 200, highWater: 10); // 5% — partial
|
||||
Assert.That(_sink.Emitted[0].Bucket, Is.EqualTo(PlayBucket.Partial));
|
||||
}
|
||||
|
||||
// Opening a new session while one is still open closes (and records) the prior one — a track-switch
|
||||
// that did not route through Close still records the superseded listen. Two complete plays result.
|
||||
[Test]
|
||||
public void OnPlaybackStarted_WhileOpen_ClosesPriorSession()
|
||||
{
|
||||
_tracker.OnPlaybackStarted("first");
|
||||
_tracker.SetDuration(100);
|
||||
_tracker.OnProgress(95); // first: complete
|
||||
_tracker.OnPlaybackStarted("second"); // supersedes — records first
|
||||
_tracker.SetDuration(100);
|
||||
_tracker.OnProgress(95); // second: complete
|
||||
_tracker.Close();
|
||||
|
||||
Assert.That(_sink.Emitted.Select(e => e.Key), Is.EqualTo(new[] { "first", "second" }));
|
||||
Assert.That(_sink.Emitted.All(e => e.Bucket == PlayBucket.Complete), Is.True);
|
||||
}
|
||||
|
||||
// A replay (open the same key again after closing) is a second, independent play (§1d).
|
||||
[Test]
|
||||
public void Replay_RecordsTwoPlays()
|
||||
{
|
||||
PlaySession("t", duration: 100, highWater: 95);
|
||||
PlaySession("t", duration: 100, highWater: 95);
|
||||
Assert.That(_sink.Emitted, Has.Count.EqualTo(2));
|
||||
}
|
||||
}
|
||||
@@ -144,6 +144,154 @@ public class QueueServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PlayRelease_ViaLiveQueueItems_PreservesTracksAndJumpsToIndex()
|
||||
{
|
||||
// Regression guard for the aliasing bug: OnQueueJump calls PlayRelease(QueueService.Items, index).
|
||||
// Items returns the backing list directly; without a defensive copy, the cast
|
||||
// "tracks as IReadOnlyList<TrackDto>" aliases _items, so _items.Clear() also clears list,
|
||||
// and _items.AddRange(list) adds nothing — wiping the queue and playing nothing.
|
||||
await _queue.PlayRelease(Tracks(4)); // populate the live queue
|
||||
|
||||
// Jump to index 2 via the live Items reference, exactly as OnQueueJump does.
|
||||
await _queue.PlayRelease(_queue.Items, 2);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
// The queue must survive — all four tracks still present, in order.
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(4));
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3", "track-4" }));
|
||||
// CurrentIndex must be the jumped-to slot.
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||
// Current must be the right track.
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
|
||||
// The player must have streamed the jumped-to track.
|
||||
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-3"));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Arm: prerender-safe load without streaming (release embed) ---
|
||||
|
||||
[Test]
|
||||
public void Arm_LoadsTracksAtIndexZero_WithoutStreaming()
|
||||
{
|
||||
var tracks = Tracks(3);
|
||||
|
||||
_queue.Arm(tracks);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.IsArmed, Is.True);
|
||||
// Arming is interop-free state only: nothing must have been streamed yet.
|
||||
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Arm_WithSingleTrackRelease_ArmsAOneItemQueueWithoutError()
|
||||
{
|
||||
_queue.Arm(Tracks(1));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(1));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.IsArmed, Is.True);
|
||||
Assert.That(_queue.HasNext, Is.False);
|
||||
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Arm_WithEmptyTracks_IsNoOpAndLeavesQueueDisarmed()
|
||||
{
|
||||
_queue.Arm(Enumerable.Empty<TrackDto>());
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Is.Empty);
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||
Assert.That(_queue.IsArmed, Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Start_OnArmedQueue_StreamsTrackZeroAndDisarms()
|
||||
{
|
||||
// Models the embed's first-gesture path: FramePlayer arms the queue (no stream), then the
|
||||
// play click routes through Start().
|
||||
_queue.Arm(Tracks(3));
|
||||
|
||||
await _queue.Start();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.IsArmed, Is.False);
|
||||
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("track-1"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Start_OnUnarmedQueue_IsNoOp()
|
||||
{
|
||||
// A non-embed flow (PlayRelease already streaming) must not be disturbed by a stray Start.
|
||||
await _queue.PlayRelease(Tracks(3));
|
||||
var streamedBefore = _player.SelectedTracks.Count;
|
||||
|
||||
await _queue.Start();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.IsArmed, Is.False);
|
||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
|
||||
"Start on an unarmed queue must not re-stream");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ArmedQueue_StartedThenAdvancesThroughWholeReleaseOnTrackEnded()
|
||||
{
|
||||
_queue.Arm(Tracks(3));
|
||||
await _queue.Start();
|
||||
|
||||
_player.RaiseTrackEnded(); // → track-2
|
||||
_player.RaiseTrackEnded(); // → track-3
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||
Assert.That(_player.SelectedTracks.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Arm_RaisesQueueChanged()
|
||||
{
|
||||
var raised = false;
|
||||
_queue.QueueChanged += () => raised = true;
|
||||
|
||||
_queue.Arm(Tracks(2));
|
||||
|
||||
Assert.That(raised, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Clear_DisarmsAnArmedQueue()
|
||||
{
|
||||
_queue.Arm(Tracks(2));
|
||||
|
||||
_queue.Clear();
|
||||
|
||||
Assert.That(_queue.IsArmed, Is.False);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
// --- Next / Previous mechanics and bounds ---
|
||||
|
||||
[Test]
|
||||
@@ -210,14 +358,17 @@ public class QueueServiceTests
|
||||
// --- Enqueue / EnqueueRange ---
|
||||
|
||||
[Test]
|
||||
public void Enqueue_AppendsWithoutChangingCurrentOrStartingPlayback()
|
||||
public void Enqueue_IntoDormantQueue_StagesCoherentIndexWithoutStartingPlayback()
|
||||
{
|
||||
// OQ8 (Phase 17 wave 17.1): the first add into a dormant queue stages a coherent
|
||||
// CurrentIndex (0) so the next play/skip is correct, but does NOT begin playback. (This
|
||||
// supersedes the pre-OQ8 expectation that a dormant Enqueue left CurrentIndex at -1.)
|
||||
_queue.Enqueue(new TrackDto { EntryKey = "a", TrackName = "A" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(1));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||
});
|
||||
}
|
||||
@@ -245,6 +396,296 @@ public class QueueServiceTests
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(3));
|
||||
}
|
||||
|
||||
// --- Move (reorder) — Phase 17 wave 17.1 ---
|
||||
|
||||
[Test]
|
||||
public async Task Move_ReordersItems_AndRaisesQueueChangedOnce()
|
||||
{
|
||||
// T1: Move(2, 0) on a 4-item queue reorders correctly; QueueChanged fired once.
|
||||
await _queue.PlayRelease(Tracks(4));
|
||||
var changed = 0;
|
||||
_queue.QueueChanged += () => changed++;
|
||||
|
||||
_queue.Move(2, 0);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-3", "track-1", "track-2", "track-4" }));
|
||||
Assert.That(changed, Is.EqualTo(1));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Move_MovingOtherItemsAroundCurrent_KeepsSameTrackCurrent()
|
||||
{
|
||||
// T2 (moving others): with CurrentIndex on track-2, moving an item across it leaves track-2 current.
|
||||
await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1
|
||||
|
||||
_queue.Move(3, 0); // move track-4 to the front → [4,1,2,3]; track-2 now at index 2
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Move_MovingCurrentItself_UpdatesCurrentIndexToNewSlot()
|
||||
{
|
||||
// T2 (moving current): moving the current track updates CurrentIndex to its new slot.
|
||||
await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1
|
||||
|
||||
_queue.Move(1, 3); // move track-2 to the end → [1,3,4,2]
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(3));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Move_DoesNotStreamAnything()
|
||||
{
|
||||
// T3: Move drives no playback — the fake player records no further SelectTrackStreaming call.
|
||||
await _queue.PlayRelease(Tracks(4));
|
||||
var streamedBefore = _player.SelectedTracks.Count;
|
||||
|
||||
_queue.Move(3, 0);
|
||||
_queue.Move(0, 2);
|
||||
|
||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
|
||||
"Move must not stream — it is a pure list/index mutation");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Move_ReorderingCurrentTrack_AutoAdvancePointsAtCorrectNext()
|
||||
{
|
||||
// T10: reorder the currently-playing item; the next auto-advance follows the new order.
|
||||
await _queue.PlayRelease(Tracks(4)); // current = track-1 at index 0
|
||||
|
||||
_queue.Move(0, 2); // move current track-1 to index 2 → [2,3,1,4]; track-1 still current at index 2
|
||||
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(2));
|
||||
|
||||
_player.RaiseTrackEnded(); // organic end of track-1 → advance to index 3 (track-4)
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(3));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-4"));
|
||||
Assert.That(_player.SelectedTracks.Last().EntryKey, Is.EqualTo("track-4"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Move_OutOfRangeOrEqualIndices_AreNoOpsAndDoNotRaiseQueueChanged()
|
||||
{
|
||||
// T8 (Move half): out-of-range and equal-index moves do not throw and do not fire QueueChanged.
|
||||
await _queue.PlayRelease(Tracks(3));
|
||||
var raised = false;
|
||||
_queue.QueueChanged += () => raised = true;
|
||||
|
||||
Assert.DoesNotThrow(() =>
|
||||
{
|
||||
_queue.Move(-1, 0);
|
||||
_queue.Move(0, 5);
|
||||
_queue.Move(5, 0);
|
||||
_queue.Move(1, 1);
|
||||
});
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(raised, Is.False);
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
// --- RemoveAt — Phase 17 wave 17.1 ---
|
||||
|
||||
[Test]
|
||||
public async Task RemoveAt_AfterCurrent_LeavesCurrentIndexUnchanged()
|
||||
{
|
||||
// T4: removing a track after the current leaves CurrentIndex unchanged, item gone.
|
||||
await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1
|
||||
|
||||
_queue.RemoveAt(3); // remove track-4 (after current)
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey),
|
||||
Is.EqualTo(new[] { "track-1", "track-2", "track-3" }));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveAt_BeforeCurrent_DecrementsCurrentIndex_SameTrackStaysCurrent()
|
||||
{
|
||||
// T5: removing before the current decrements CurrentIndex; the same track stays current.
|
||||
await _queue.PlayRelease(Tracks(4), startIndex: 2); // current = track-3 at index 2
|
||||
|
||||
_queue.RemoveAt(0); // remove track-1 (before current)
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveAt_OfCurrent_NotLast_DoesNotStop_AndResolvesToNextOccupant()
|
||||
{
|
||||
// T6 (not last): removing the current track does not stop the player; CurrentIndex resolves
|
||||
// to the new occupant of that slot (the next track).
|
||||
await _queue.PlayRelease(Tracks(4), startIndex: 1); // current = track-2 at index 1
|
||||
var streamedBefore = _player.SelectedTracks.Count;
|
||||
|
||||
_queue.RemoveAt(1); // remove current track-2 → [1,3,4]; index 1 now holds track-3
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-3"));
|
||||
Assert.That(_player.StopCount, Is.EqualTo(0), "RemoveAt must not stop playback (C2)");
|
||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
|
||||
"RemoveAt must not re-stream");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveAt_OfCurrent_WhenLastSlot_ResolvesToDormantMinusOne()
|
||||
{
|
||||
// T6 (last slot, others remain): removing the current track when it is the last item has no
|
||||
// next occupant → CurrentIndex resolves to -1; playback is not stopped.
|
||||
await _queue.PlayRelease(Tracks(3), startIndex: 2); // current = track-3 at last index 2
|
||||
|
||||
_queue.RemoveAt(2);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||
Assert.That(_queue.Current, Is.Null);
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-1", "track-2" }));
|
||||
Assert.That(_player.StopCount, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveAt_OfLastRemainingTrack_EmptiesAndGoesDormant()
|
||||
{
|
||||
// T7: removing the last remaining item → empty + dormant; QueueChanged fired.
|
||||
await _queue.PlayRelease(Tracks(1));
|
||||
var raised = false;
|
||||
_queue.QueueChanged += () => raised = true;
|
||||
|
||||
_queue.RemoveAt(0);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Is.Empty);
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||
Assert.That(_queue.Current, Is.Null);
|
||||
Assert.That(raised, Is.True);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RemoveAt_OutOfRange_IsNoOpAndDoesNotRaiseQueueChanged()
|
||||
{
|
||||
// T8 (RemoveAt half): out-of-range removal does not throw and does not fire QueueChanged.
|
||||
await _queue.PlayRelease(Tracks(3));
|
||||
var raised = false;
|
||||
_queue.QueueChanged += () => raised = true;
|
||||
|
||||
Assert.DoesNotThrow(() =>
|
||||
{
|
||||
_queue.RemoveAt(-1);
|
||||
_queue.RemoveAt(3);
|
||||
});
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(raised, Is.False);
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(3));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Dormant Enqueue (OQ8: pure append, coherent index, no auto-play) — Phase 17 wave 17.1 ---
|
||||
|
||||
[Test]
|
||||
public async Task Enqueue_IntoDormantQueue_LeavesCoherentCurrentIndexWithoutPlaying_ThenPlayStartsCorrectly()
|
||||
{
|
||||
// T9: Enqueue into a dormant queue stages a coherent CurrentIndex (0) but does not auto-play;
|
||||
// a subsequent play (Next from the staged position, or PlayRelease) behaves per OQ8.
|
||||
_queue.Enqueue(new TrackDto { EntryKey = "a", TrackName = "A" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0), "first add stages a coherent index");
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("a"));
|
||||
Assert.That(_player.SelectedTracks, Is.Empty, "add is not play — nothing streamed");
|
||||
Assert.That(_queue.IsArmed, Is.False, "dormant Enqueue does not arm");
|
||||
});
|
||||
|
||||
_queue.Enqueue(new TrackDto { EntryKey = "b", TrackName = "B" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
// Second add appends without disturbing the staged current.
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.HasNext, Is.True);
|
||||
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||
});
|
||||
|
||||
// The coherent index means a skip-forward streams the right track without a prior play.
|
||||
await _queue.Next();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(1));
|
||||
Assert.That(_player.SelectedTracks.Single().EntryKey, Is.EqualTo("b"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EnqueueRange_IntoDormantQueue_StagesCoherentIndexWithoutPlaying()
|
||||
{
|
||||
// T9 (range variant): EnqueueRange into a dormant queue stages index 0, streams nothing.
|
||||
_queue.EnqueueRange(Tracks(3));
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(3));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-1"));
|
||||
Assert.That(_player.SelectedTracks, Is.Empty);
|
||||
Assert.That(_queue.IsArmed, Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Enqueue_IntoActiveQueue_DoesNotMoveCurrentIndex()
|
||||
{
|
||||
// Guard the non-dormant path stays unchanged: appending while playing leaves current put.
|
||||
await _queue.PlayRelease(Tracks(2), startIndex: 1); // current = track-2 at index 1
|
||||
|
||||
_queue.Enqueue(new TrackDto { EntryKey = "tail", TrackName = "Tail" });
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(1), "active-queue Enqueue must not disturb current");
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Clear ---
|
||||
|
||||
[Test]
|
||||
@@ -264,6 +705,75 @@ public class QueueServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
// --- ClearUpcoming (OQ5: keep the current track, drop the up-next) — Phase 17 wave 17.2 ---
|
||||
|
||||
[Test]
|
||||
public async Task ClearUpcoming_KeepsCurrentTrack_DropsTheRest_WithoutStopping()
|
||||
{
|
||||
// Current = track-2; ClearUpcoming leaves only track-2 at index 0 and does not stop the player.
|
||||
await _queue.PlayRelease(Tracks(4), startIndex: 1);
|
||||
var streamedBefore = _player.SelectedTracks.Count;
|
||||
|
||||
_queue.ClearUpcoming();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items.Select(t => t.EntryKey), Is.EqualTo(new[] { "track-2" }));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(_queue.Current!.EntryKey, Is.EqualTo("track-2"));
|
||||
Assert.That(_queue.HasNext, Is.False);
|
||||
Assert.That(_queue.HasPrevious, Is.False);
|
||||
Assert.That(_player.StopCount, Is.EqualTo(0), "ClearUpcoming must not stop playback (C2)");
|
||||
Assert.That(_player.SelectedTracks, Has.Count.EqualTo(streamedBefore),
|
||||
"ClearUpcoming must not re-stream");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ClearUpcoming_RaisesQueueChangedOnce()
|
||||
{
|
||||
await _queue.PlayRelease(Tracks(3));
|
||||
var changed = 0;
|
||||
_queue.QueueChanged += () => changed++;
|
||||
|
||||
_queue.ClearUpcoming();
|
||||
|
||||
Assert.That(changed, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ClearUpcoming_WhenOnlyCurrentRemains_IsNoOpAndDoesNotRaiseQueueChanged()
|
||||
{
|
||||
await _queue.PlayRelease(Tracks(1));
|
||||
var raised = false;
|
||||
_queue.QueueChanged += () => raised = true;
|
||||
|
||||
_queue.ClearUpcoming();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Has.Count.EqualTo(1));
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(0));
|
||||
Assert.That(raised, Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClearUpcoming_OnEmptyQueue_IsNoOpAndDoesNotRaiseQueueChanged()
|
||||
{
|
||||
var raised = false;
|
||||
_queue.QueueChanged += () => raised = true;
|
||||
|
||||
_queue.ClearUpcoming();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(_queue.Items, Is.Empty);
|
||||
Assert.That(_queue.CurrentIndex, Is.EqualTo(-1));
|
||||
Assert.That(raised, Is.False);
|
||||
});
|
||||
}
|
||||
|
||||
// --- QueueChanged notifications ---
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using DeepDrftModels.Enums;
|
||||
using DeepDrftPublic.Client.Services;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
using Microsoft.JSInterop;
|
||||
using Microsoft.JSInterop.Infrastructure;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for the Phase 16 share tracker (<see cref="ShareTracker"/>): the per-(target,channel)
|
||||
/// debounce (§1b — at most one event per target+channel per 60s window per session). The tracker fires
|
||||
/// through a beacon that wraps <see cref="IJSRuntime"/>; the tests use a no-op JS runtime (the send is
|
||||
/// fire-and-forget and its outcome is irrelevant) and assert on the debounce decision via the bool the
|
||||
/// recorder returns — true when an event fired, false when debounced.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ShareTrackerTests
|
||||
{
|
||||
// sendBeacon interop is fire-and-forget; the tracker never reads the result, so a no-op runtime that
|
||||
// returns default for any invocation is sufficient to exercise the debounce logic.
|
||||
private sealed class NoopJsRuntime : IJSRuntime
|
||||
{
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
|
||||
=> ValueTask.FromResult<TValue>(default!);
|
||||
|
||||
public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
|
||||
=> ValueTask.FromResult<TValue>(default!);
|
||||
}
|
||||
|
||||
// Minimal NavigationManager so the tracker can compose the (unused-in-test) beacon URL.
|
||||
private sealed class TestNavigationManager : NavigationManager
|
||||
{
|
||||
public TestNavigationManager() => Initialize("https://deepdrft.test/", "https://deepdrft.test/");
|
||||
protected override void NavigateToCore(string uri, bool forceLoad) { }
|
||||
}
|
||||
|
||||
// Fixed-value anon-id provider so the tracker can attach a token without JS interop. Treated as
|
||||
// already warmed (Current returns the value); EnsureLoadedAsync is a no-op here.
|
||||
private sealed class StubAnonIdProvider : IAnonIdProvider
|
||||
{
|
||||
public StubAnonIdProvider(string? current) => Current = current;
|
||||
public string? Current { get; }
|
||||
public ValueTask EnsureLoadedAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private ShareTracker _tracker = null!;
|
||||
private readonly DateTimeOffset _t0 = new(2026, 6, 19, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
=> _tracker = new ShareTracker(
|
||||
new BeaconInterop(new NoopJsRuntime()),
|
||||
new StubAnonIdProvider("anon-1"),
|
||||
new TestNavigationManager());
|
||||
|
||||
// A copy-link records one share with channel = link.
|
||||
[Test]
|
||||
public void RecordShare_CopyLink_FiresOnce()
|
||||
=> Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
||||
|
||||
// A copy-embed records one share with channel = embed — distinct (target,channel) from the link copy.
|
||||
[Test]
|
||||
public void RecordShare_CopyEmbedAfterLink_FiresSeparately()
|
||||
{
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Embed, _t0), Is.True,
|
||||
"embed is a different channel from link — not debounced against it");
|
||||
}
|
||||
|
||||
// An immediate repeat copy of the same (target, channel) within the window is debounced.
|
||||
[Test]
|
||||
public void RecordShare_ImmediateRepeat_IsDebounced()
|
||||
{
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0.AddSeconds(5)), Is.False);
|
||||
}
|
||||
|
||||
// After the 60s window elapses, the same (target, channel) fires again.
|
||||
[Test]
|
||||
public void RecordShare_AfterWindow_FiresAgain()
|
||||
{
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0), Is.True);
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "k", ShareChannel.Link, _t0.AddSeconds(61)), Is.True);
|
||||
}
|
||||
|
||||
// Different targets debounce independently — sharing track A then track B both fire.
|
||||
[Test]
|
||||
public void RecordShare_DifferentTargets_FireIndependently()
|
||||
{
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "a", ShareChannel.Link, _t0), Is.True);
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "b", ShareChannel.Link, _t0), Is.True);
|
||||
}
|
||||
|
||||
// A track key and a release key are distinct targets even if the key string collides.
|
||||
[Test]
|
||||
public void RecordShare_TrackVsRelease_AreDistinctTargets()
|
||||
{
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Track, "x", ShareChannel.Link, _t0), Is.True);
|
||||
Assert.That(_tracker.RecordShare(ShareTargetType.Release, "x", ShareChannel.Link, _t0), Is.True);
|
||||
}
|
||||
|
||||
// A blank target key never fires (defensive — the popover guards too).
|
||||
[Test]
|
||||
public void RecordShare_BlankKey_DoesNotFire()
|
||||
=> Assert.That(_tracker.RecordShare(ShareTargetType.Track, " ", ShareChannel.Link, _t0), Is.False);
|
||||
}
|
||||
@@ -152,6 +152,7 @@ A small set of items that are real but don't fit a phase yet. Surface them when
|
||||
- **Identity / accounts.** Currently no user concept. Needed before web upload (2.4); also a precondition for favourites, listening history, per-user playlists. Decide the shape before any of those lands. `[speculative]` until Daniel signals interest.
|
||||
- **`ITrackService` interface.** Audit-suggested. Low value today (one consumer pair); higher value when the test surface expands beyond FileDatabase.
|
||||
- **Test coverage outside FileDatabase.** Tests today cover the FileDatabase subsystem comprehensively and nothing else. As features in Phases 1–4 land, test scope should expand — at minimum `WavOffsetService`, `AudioProcessor`, `TrackService` (both sides), and the streaming player services. Not a phase of its own; an attached cost to feature work.
|
||||
- *Real-Postgres integration harness (deferred per Daniel — big lift).* The Phase 16 distinct-listener aggregation LINQ (`EventRepository.CountDistinctListenersAsync` / `...ForTrackAsync` / `...ForReleaseAsync`) is currently exercised only against the EF in-memory provider, which does not validate real Npgsql SQL translation — the distinct-count queries want translation verification against an actual Postgres instance. More broadly, an integration-test harness against a real Postgres is the deferred prerequisite for trusting any non-trivial LINQ across the EF surface. **Explicitly deferred by Daniel (big lift); a note for now, not committed work — no timeline.**
|
||||
|
||||
---
|
||||
|
||||
@@ -245,21 +246,99 @@ The phase deferred behind the home-hero **Plays** stat card (`NowPlayingStats.ra
|
||||
|
||||
**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**.
|
||||
**Completion buckets (settled).** `partial` < 30%, `sampled` 30–80%, `complete` > 80% — exhaustive, non-overlapping; headline plays = sum of all three (D1 resolved). The engagement floor (D2) drops trivial skips: a listen counts only at **≥3s OR ≥5% of duration, whichever smaller**.
|
||||
|
||||
**Sequenced as four waves.** `16.A → 16.B`, `16.A → 16.C`, `16.A → 16.D`. 16.A is the only cold-start wave.
|
||||
**Sequenced BOTTOM-UP (Daniel directive 2026-06-19): foundation first, the live card LAST.** Reverses the earlier "visible win early" framing — Daniel does not care about the live card until the whole substrate and all metrics are finished. Strict chain: `16.1 → 16.2 → 16.3 → (16.4 optional) → 16.5`. 16.1 is the only cold-start wave; 16.5 (the card) is the capstone built last.
|
||||
|
||||
- **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.
|
||||
- **16.1 — Foundation: capture seam + transport + event log (nothing reads it yet).** Player-service play tracker (high-water mark + engagement floor), share tracker in `SharePopover` (debounced), `sendBeacon` interop + unload handler, `POST api/event/{play,share}` (proxied, rate-limited), append-only `play_event`/`share_event` log + incremental `play_counter` rollup, server-side release resolution + derived release totals. **No `anonId` yet; no card consumption.** Cold-start wave. **Landed:** 2026-06-19 on dev. Migration `20260619155610_AddPlayShareTelemetry` authored but not applied (Daniel-gated).
|
||||
- **16.2 — Completion-bucket classification + shares.** Three-bucket classification (D1) correct and exhaustive end to end (tracker → payload → log → per-bucket counter columns); share-channel split (link/embed). **Depends on 16.1.** **Landed:** absorbed into 16.1 — all §4.1 deliverables shipped inside the wave 16.1 foundation: `PlayBucket` enum (`Partial`/`Sampled`/`Complete`, exhaustive/non-overlapping) wired tracker → payload → log → per-bucket counter columns (`PlayCounter.PartialCount`/`SampledCount`/`CompleteCount`, `TotalPlays` computed sum); `ShareChannel` enum (`Link`/`Embed`) on `ShareEvent.Channel`; API-boundary bucket validation. The only §4.2 item not built is the optional `share_count` rollup on `play_counter` — correctly deferred (shares are not on the home-card hot path; per-target reads are speculative wave 16.4).
|
||||
- **16.3 — Unique-listener `anonId` layer (lowest-priority metric, D5).** Option A: mint/read client first-party `localStorage` id, thread `anonId` onto payloads (nullable in the log), count distinct server-side (all-time, D3). **The last metric layer** — folded into "everything finished," explicitly last-built of the substrate. **Depends on 16.1; builds on 16.2.** **Landed:** 2026-06-19 on dev (merge 297805b). No migration — `anon_id varchar(64)` columns and `IX_play_event_anon_id`/`IX_share_event_anon_id` indexes already shipped in the 16.1 migration.
|
||||
- **16.4 — Per-target / CMS stats surfaces.** `[speculative — not built; passed over]` — `GET api/stats/{track,release}/{key}` + CMS analytics views (bucket/channel splits, leaderboards). Not committed; the event log already supports it. **Skipped as speculative per Daniel (2026-06-19); available to build later if a surface wants it.** Off the critical path to the card. **Depends on 16.1–16.3.**
|
||||
- **16.5 — Home Plays-card payoff (CAPSTONE, built LAST).** Extend `HomeStatsDto` + `GetHomeStatsAsync` with `TotalPlays` (+ secondary line = unique listeners, D7); flip `NowPlayingStats`'s third card from placeholder to live through the existing `GET api/stats/home` round-trip. **The final wave — built only once 16.1–16.3 are in place.** **Landed:** 2026-06-19 on dev.
|
||||
|
||||
**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.
|
||||
**Phase 16 is complete.** Waves 16.1, 16.2 (absorbed into 16.1), 16.3, and 16.5 all landed on dev (2026-06-19). Wave 16.4 was speculative and deliberately skipped. Privacy footer line (`DeepDrftFooter.razor` `.deepdrft-footer-privacy` disclosure) also landed. The anonymous play/share substrate, unique-listener metric, and live Plays card are in place.
|
||||
|
||||
**Product decisions D1–D7 — RESOLVED (Daniel 2026-06-19, spec §10).** D1 (three-bucket sampled), D2 (engagement floor on, ≥3s/≥5%), D4 (release plays derived server-side), D5 (Option A — client-minted first-party `localStorage` id, metric labelled "listeners," fingerprinting rejected) resolved on explicit pick. D3 (all-time window), D6 (incremental-on-write rollup), D7 (card secondary = unique listeners) resolved-by-default — low-risk to revisit during their wave. Road-not-taken preserved 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.
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Player-Bar Queue View
|
||||
|
||||
Phase 11 (wave 11.F) built the queue **engine** (`IQueueService`/`QueueService` — ordered playback,
|
||||
auto-advance, skip-prev/next, armed-idle release embeds) but gave it **no UI**. A listener can start
|
||||
an album and skip through it, but cannot see what is next, reorder it, drop a track, or hand-build a
|
||||
queue. Phase 17 surfaces the queue as a first-class, visible, editable object in the player bar —
|
||||
**without** moving any player/queue state out of the layout-level `AudioPlayerProvider` (project
|
||||
memory; the cascade stays at layout level) and **without** changing the streaming/playback seam.
|
||||
|
||||
Four Daniel-stated capabilities, gated by a single new **Queue** toggle button (shown only when a
|
||||
queue is loaded, mirroring the skip-affordance gating so single-track play is byte-for-byte its
|
||||
pre-queue self):
|
||||
|
||||
1. **Queue button** in the bar — below the transport controls, left of the timestamp.
|
||||
2. **Non-Fixed (overlay) mode** — a centered, mostly-square panel (borrowing the Phase 15
|
||||
visualizer-popover idiom) listing the queue with **drag-reorder + per-track removal**.
|
||||
3. **Fixed (embed) mode** — the queue list is **always shown below** the bar controls (not a toggle),
|
||||
**read-only** for shared release queues (no reorder, no remove); the embed iframe is resized to fit
|
||||
the panel.
|
||||
4. **Add to Queue** affordance — an icon button + tooltip beside every detail-page play button for a
|
||||
release (→ `EnqueueRange`) or track (→ `Enqueue`), lighting up the dormant `Enqueue` path. Scoped
|
||||
to the detail-page play sites (Cut header, Cut track rows, Session/Mix hero); **`ReleaseGallery`
|
||||
browse-grid cards are excluded** (no play button today — deferred per OQ10, captured in `TODO.md`).
|
||||
|
||||
**Architectural spine.** Engine grows **two additive members** — `Move(from, to)` and
|
||||
`RemoveAt(index)` — interop-free state mutations that re-emit `QueueChanged` and **never re-stream or
|
||||
interrupt the playing track** (the engine's stated open/closed posture; existing members untouched),
|
||||
**plus a small additive `Enqueue`-into-dormant affordance** (OQ8: append leaves a coherent
|
||||
`CurrentIndex` so the next play/skip is correct, without auto-playing).
|
||||
Both view modes render **one shared `QueueList` presentational component** off the same cascaded
|
||||
`IQueueService.Items`, differing only in presentation + an `Editable` flag (project memory: *one
|
||||
source, multiple views*). Reorder/remove run safely during prerender (no JS) — only playback
|
||||
transitions touch interop.
|
||||
|
||||
**Sequenced as three waves.** `17.1 → {17.2, 17.3}`. **17.1 (engine `Move`/`RemoveAt` + the shared
|
||||
`QueueList` view) is the cold-start prerequisite**, settled and independent of the UI decisions —
|
||||
it can begin immediately. **Landed:** 2026-06-19 on dev. 17.2 (docked overlay, editable,
|
||||
`MudDropContainer` reorder) and 17.3 (Fixed embed panel + snippet resize — **the OQ1
|
||||
Option-A-vs-B feasibility call is made here**) hang off it and are largely parallel. Add-to-Queue
|
||||
split to a standalone 17.4 (needs only the existing `Enqueue`/`EnqueueRange`, not 17.1's new
|
||||
members). **Landed (17.2):** 2026-06-19 on dev. **Landed (17.4):** 2026-06-19 on dev. 17.3 remains
|
||||
pending.
|
||||
|
||||
Full design — goal, constraints, use cases, acceptance criteria, test cases, wave decomposition, and
|
||||
the open-question set: `product-notes/phase-17-player-queue-view.md`.
|
||||
|
||||
**Open questions — all 11 resolved (Daniel, 2026-06-19; spec §10).**
|
||||
|
||||
- **OQ1** → **Option A, conditional** — collapse/expand toggle *if* the embed snippet can dynamically
|
||||
resize the iframe (`postMessage` → host resize handshake), **else fall back to Option B** (omit the
|
||||
button); A preferred, B fallback, deciding factor = iframe-resize feasibility, **determined during
|
||||
17.3**.
|
||||
- **OQ2** → **yes, both modes** — clicking a queued row jumps playback to that track in the docked
|
||||
overlay *and* the read-only embed; reuses `PlayRelease(Items, index)`.
|
||||
- **OQ3 + OQ11** (jointly) → **the currently-playing track cannot be removed at all** — no "remove
|
||||
current" action; the × is suppressed on the current row. The queue empties only **organically** (the
|
||||
current track ends with nothing queued after). The engine's `RemoveAt`-of-current path (17.1) stays as
|
||||
defensive, UI-unreachable behavior.
|
||||
- **OQ4** → **`MudDropContainer` for now** (C6 softened — touch-viability is a known risk with a planned
|
||||
pivot path, not a pre-ship blocker).
|
||||
- **OQ5** → **yes, Clear in the overlay header** — but Clear must **not** stop or remove the
|
||||
currently-playing track (it keeps playing and stays in the queue; only the other queued tracks clear).
|
||||
- **OQ6** → **fixed sensible height with internal scroll past N rows** (not grow-to-cap; affects
|
||||
`EmbedSnippetBuilder.ForRelease`).
|
||||
- **OQ7** → **Material icons for now** (`QueueMusic` / `PlaylistAdd`; bespoke `DDIcons` glyph not pursued
|
||||
in Phase 17).
|
||||
- **OQ8** → **pure append** (add ≠ play; first add into a dormant queue leaves a coherent `CurrentIndex`
|
||||
via the 17.1 engine affordance, no auto-play).
|
||||
- **OQ9** → **exclude `StreamNowButton`** (no fixed track until one resolves).
|
||||
- **OQ10** → **deferred** (cards get no Add-to-Queue in Phase 17; deferred card work captured in
|
||||
`TODO.md`).
|
||||
- **None block 17.1.**
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
# TODO.md — Known issues and bugs
|
||||
# TODO.md — Known issues and deferred work
|
||||
|
||||
## Bugs
|
||||
|
||||
No open bugs.
|
||||
|
||||
## Deferred work
|
||||
|
||||
Small, real items deferred from a landed or in-flight phase. Larger directions live in `PLAN.md`.
|
||||
|
||||
- **ReleaseGallery card play + Add-to-Queue affordance (deferred from Phase 17 item 4).**
|
||||
`ReleaseGallery` browse-grid cards have no play button today — they navigate to detail. Revisit
|
||||
giving them a play + Add-to-Queue affordance (a card-redesign question). Deferred from Phase 17
|
||||
item 4 per OQ10 (Daniel, 2026-06-19); Phase 17 scopes Add-to-Queue to the detail-page play sites
|
||||
(Cut header, Cut track rows, Session/Mix hero) only. See `product-notes/phase-17-player-queue-view.md` §5.1.
|
||||
|
||||
@@ -3,6 +3,10 @@ server {
|
||||
listen [::]:80;
|
||||
server_name __DOMAIN_APP__;
|
||||
|
||||
# Allow audio file uploads up to ~1.86 GB (matches the per-request ceiling on api/track/upload
|
||||
# and api/track/{id}/replace-audio). nginx default is 1 MB, which would 413 any large upload.
|
||||
client_max_body_size 2000m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:__PORT_MANAGER__;
|
||||
proxy_http_version 1.1;
|
||||
@@ -15,5 +19,12 @@ server {
|
||||
# WebSocket support (Blazor InteractiveServer SignalR circuits)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
|
||||
# Large audio uploads stream over the SignalR WebSocket circuit for several minutes.
|
||||
# nginx's 60 s default would drop the connection mid-transfer and silently kill the
|
||||
# Blazor circuit — the app never sees an error, so nothing is logged. 1200 s matches
|
||||
# the Upload:ResponseTimeoutSeconds budget already configured in the application.
|
||||
proxy_read_timeout 1200s;
|
||||
proxy_send_timeout 1200s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,5 +15,11 @@ server {
|
||||
# WebSocket support (Blazor Server SignalR circuits + WASM interop)
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $http_connection;
|
||||
|
||||
# Blazor Server SignalR circuits can be long-lived. Raise timeouts above the 60 s
|
||||
# nginx default so idle-but-active circuits (e.g. during audio streaming) are not
|
||||
# silently dropped.
|
||||
proxy_read_timeout 1200s;
|
||||
proxy_send_timeout 1200s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
# 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`.
|
||||
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
|
||||
@@ -65,17 +72,16 @@ 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%)** — see decision **D1** below. **Recommendation: count the middle band as
|
||||
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.
|
||||
- **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.
|
||||
|
||||
Alternative considered: fold the middle into "complete" (threshold becomes "≥30% = a real play,
|
||||
else partial"). Simpler, two buckets — but it throws away the distinction between "listened to half"
|
||||
and "listened to the end," which is the most editorially interesting signal for a music collective
|
||||
("which mixes do people actually finish?"). Rejected in favour of three buckets, but it's a genuine
|
||||
Daniel call (**D1**).
|
||||
*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`
|
||||
@@ -113,10 +119,13 @@ per session. Cheap, prevents the obvious gaming, and matches how "copied!" alrea
|
||||
|
||||
### 1c. Unique listeners (stretch / "plus" — lower priority)
|
||||
|
||||
A **unique listener** is an approximate distinct-listener count over a window (all-time, or rolling
|
||||
30 days — **D3**), tied to a track or release. This is the metric most in tension with the no-PII
|
||||
constraint, and it is explicitly the **last** thing to build (§6). It is approximate by design — we
|
||||
are not building identity, we are estimating reach. Mechanism options and recommendation: **§3**.
|
||||
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)
|
||||
|
||||
@@ -137,13 +146,14 @@ are not building identity, we are estimating reach. Mechanism options and recomm
|
||||
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%` →
|
||||
ten `partial` plays. **Recommendation (D2): apply a minimum-engagement floor before a play counts
|
||||
at all** — e.g. playback must reach **≥ 3 seconds OR ≥ 5% of duration** (whichever is smaller) for
|
||||
the listen to register as a play. Below the floor it's a *preview/skip*, not a play, and is dropped
|
||||
entirely. 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. **D2 is a
|
||||
Daniel call** — the alternative is "every started playback counts, floor = 0," which is simpler and
|
||||
defensible ("they hit play, it's a play") but inflates the number on a browsing session.
|
||||
ten `partial` plays. **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
|
||||
@@ -236,14 +246,14 @@ wants both (the metric is "tied to individual tracks and releases"). Options:
|
||||
client plumbing; the release dimension is always correct even for plays that started without
|
||||
release context (e.g. StreamNow random track).
|
||||
|
||||
**Recommendation: 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 it means 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. This also means
|
||||
**release play counts are derived** (sum of plays of the release's tracks) rather than separately
|
||||
counted — which is exactly right for multi-track Cuts and trivially correct for single-track
|
||||
Session/Mix. (**D4**: confirm release plays = sum-of-track-plays, not a separately-counted "release
|
||||
was played" event. Recommend derived.)
|
||||
**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.
|
||||
@@ -273,9 +283,11 @@ not ad-revenue telemetry — the "90s visitor counter vibe" Daniel wants). Measu
|
||||
|
||||
## 3. Privacy-light anonymity mechanism (unique listeners)
|
||||
|
||||
This is the decision most in tension with "no PII / anonymous only," and the one most wanting Daniel's
|
||||
eyes (**D5**). The unique-listener metric needs *some* notion of "the same anonymous listener seen
|
||||
again" without identifying who they are. Three mechanism families, with trade-offs:
|
||||
**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)
|
||||
|
||||
@@ -325,10 +337,10 @@ Derive an id from canvas/font/hardware fingerprinting signals.
|
||||
actively killing; it tracks across sites and survives storage-clearing. **Directly contradicts
|
||||
"privacy-light."** Rejected outright — listed only for completeness.
|
||||
|
||||
### Recommendation
|
||||
### Resolution (D5 — Option A)
|
||||
|
||||
**Option A (client-minted random first-party id) as the primary mechanism, with the metric honestly
|
||||
labelled.** Reasons:
|
||||
**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.
|
||||
@@ -339,12 +351,18 @@ labelled.** Reasons:
|
||||
- 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.
|
||||
|
||||
**If Daniel wants the stronger "stores nothing" posture, Option B is the fallback** — at the cost of
|
||||
the metric becoming daily-unique and noisier. The two are not mutually exclusive long-term (A for
|
||||
all-time reach, B-style for daily actives) but v1 should pick one. **This is D5.**
|
||||
*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.
|
||||
|
||||
**Either way, unique-listeners is the deferred §6 stretch** — the play/share counters (which need no
|
||||
`anonId` at all) ship first and stand alone.
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
@@ -373,11 +391,12 @@ small rolled-up counter table for the home-card hot read.** Reasoning:
|
||||
- (3) gets both without forcing a premature choice, and matches the existing `HomeStatsDto` pattern
|
||||
(the card reads a pre-aggregated DTO; it never queries raw).
|
||||
|
||||
For v1 the rollup can be **incremental-on-write** (the event-write transaction also bumps the counter
|
||||
row) to avoid standing up a background job. If write volume ever makes that contended, a periodic
|
||||
aggregation pass is the escape hatch — but for a collective-scale site, incremental is fine and
|
||||
simplest. (**D6**: incremental-on-write rollup vs. periodic-batch rollup. Recommend incremental for
|
||||
v1.)
|
||||
**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)
|
||||
|
||||
@@ -431,11 +450,14 @@ odometer treatment (the "90s visitor counter" vibe is *already the intended aest
|
||||
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 (optional, recommend yes):** something with texture that fits the card's existing
|
||||
two-line shape (the other two cards both have a primary + secondary). Candidates: total shares
|
||||
("N shared"), or completion rate ("N% finished" = complete / total), or unique listeners once the
|
||||
stretch lands. **Recommend completion-rate or share-count for v1**, swapping to listeners if/when
|
||||
§6 ships. (**D7** — what's the card's secondary line.)
|
||||
- **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
|
||||
@@ -447,67 +469,105 @@ its keep the moment the home number goes live, before any per-track surface or u
|
||||
|
||||
---
|
||||
|
||||
## 6. Suggested phasing
|
||||
## 6. Phasing — bottom-up (Daniel directive, 2026-06-19)
|
||||
|
||||
Sequenced so the visible payoff lands early and the privacy-sensitive stretch lands last. Each wave is
|
||||
independently shippable.
|
||||
**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.
|
||||
|
||||
- **16.A — Play & share counters (core).** The whole spine: player-service play tracker (§2.1) with
|
||||
the three-bucket classification (§1a) and engagement floor (§1d/D2); share tracker in `SharePopover`
|
||||
(§1b); `sendBeacon` interop + `POST api/event/{play,share}` endpoints (§2.2) with rate-limiting
|
||||
(§2.5); `play_event`/`share_event` log + incremental `play_counter` rollup (§4); server-side release
|
||||
resolution (§2.3, D4). **No `anonId` written yet** (unique listeners is 16.C). **Free-floating —
|
||||
the cold-start wave; nothing gates it.**
|
||||
- **16.B — Home Plays-card payoff (§5).** Extend `HomeStatsDto` + `GetHomeStatsAsync` with `TotalPlays`
|
||||
(+ chosen secondary, D7); flip `NowPlayingStats`'s third card from placeholder to live. **Depends on
|
||||
16.A** (needs the counter to read). This is the visible win — sequence it immediately after 16.A.
|
||||
- **16.C — Unique listeners (stretch / "plus").** The anonymity mechanism (§3, D5 — recommend Option
|
||||
A): mint/read the anon token, thread it onto event payloads, count distinct server-side, expose via
|
||||
the per-target stats reads and/or as the home card's secondary line. **Depends on 16.A** (extends the
|
||||
event payload + storage). **Explicitly lower priority** — 16.A+16.B deliver the agreed core; 16.C is
|
||||
the agreed stretch. Can be deferred indefinitely without stranding anything.
|
||||
- **16.D — Per-target stats surfaces (adjacent, not committed).** Detail-page play/share/listener
|
||||
display via `GET api/stats/{track,release}/{key}`; CMS analytics views (bucket splits, channel
|
||||
splits, leaderboards). **Speculative** — flagged for when a surface actually wants these. Not part of
|
||||
the agreed scope; listed so the substrate (the event log) is understood to already support it.
|
||||
Waves run in strict sequence — each builds on the layer beneath it.
|
||||
|
||||
**Hard dependencies:** `16.A → 16.B`; `16.A → 16.C`; `16.A → 16.D`. 16.A is the only cold-start wave.
|
||||
16.B and 16.C are parallel after 16.A (B is the priority; C is the stretch).
|
||||
- **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.
|
||||
- **`sendBeacon` interop** + `pagehide`/`visibilitychange` unload handler (§2.2).
|
||||
- **`POST api/event/{play,share}`** endpoints, **proxied through `DeepDrftPublic`** (§2.2), with
|
||||
IP rate-limiting + payload validation (§2.5).
|
||||
- **Append-only `play_event` / `share_event` SQL log** + **incremental `play_counter` rollup** (§4,
|
||||
D6) in `DeepDrftData`.
|
||||
- **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 `anonId` is 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 in `share_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 `anonId` layer (D5, lowest-priority metric).** The anonymity mechanism
|
||||
(§3, Option A): mint/read the client first-party `localStorage` id, thread `anonId` onto 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` +
|
||||
`GetHomeStatsAsync` with `TotalPlays` (+ the secondary line — unique listeners now available, D7);
|
||||
flip `NowPlayingStats`'s third card from the `XXX / Plays (Coming Soon)` placeholder to live,
|
||||
through the existing persistent-state-bridged `GET api/stats/home` round-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.
|
||||
|
||||
**Verify-before-build:** 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.
|
||||
**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. Open product decisions (for Daniel)
|
||||
## 10. Product decisions — RESOLVED (Daniel 2026-06-19)
|
||||
|
||||
Resolve these before 16.A is decomposed. Recommendations carried from the body; the call is Daniel's.
|
||||
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.** Recommend a third `sampled` bucket (exhaustive
|
||||
partial/sampled/complete); headline plays = sum of all three. Alt: fold middle into a binary
|
||||
partial/complete. (§1a)
|
||||
- **D2 — Engagement floor before a play counts.** Recommend a floor (~≥3s or ≥5% of duration) so
|
||||
archive-skimming doesn't inflate the count. Alt: floor = 0, every started playback counts. (§1d)
|
||||
- **D3 — Unique-listener window.** All-time vs. rolling-30-day. Recommend all-time (fits the "N
|
||||
listeners reached" framing and Option-A mechanism). (§1c) — only bites if 16.C is built.
|
||||
- **D4 — Release plays: derived or separately counted.** Recommend derived (release plays = sum of its
|
||||
tracks' plays), correct for multi-track Cuts and single-track Session/Mix alike. (§2.3)
|
||||
- **D5 — Unique-listener anonymity mechanism.** Recommend Option A (client-minted random first-party
|
||||
localStorage id, metric honestly labelled "listeners," all-time). Fallback Option B (server-derived
|
||||
salted daily token — stores nothing client-side but only yields daily-unique). Option C
|
||||
(fingerprint) rejected. **This is the headline decision.** (§3)
|
||||
- **D6 — Rollup strategy.** Recommend incremental-on-write counter for v1 (no background job). Alt:
|
||||
periodic batch aggregation. (§4.1)
|
||||
- **D7 — Home Plays-card secondary line.** Recommend completion-rate or total-shares for v1, swapping
|
||||
to unique listeners if/when 16.C ships. (§5)
|
||||
- **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 `localStorage` id, 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`.
|
||||
- When Daniel resolves the D-decisions, fold the resolutions inline (mark the section "resolved by
|
||||
Daniel YYYY-MM-DD") and flip the status to design-complete, the same way Phase 15 did.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# Phase 16 — Privacy-Note Copy (DRAFT — awaiting Daniel sign-off)
|
||||
|
||||
Status: **DRAFT — NOT FINAL. Awaiting Daniel's sign-off on (a) wording and (b) placement.** Author:
|
||||
product-designer. Date: 2026-06-19. Surface: **public site only** (`DeepDrftPublic` /
|
||||
`DeepDrftPublic.Client`). **No code, no shipped copy** — this is a copy draft for review.
|
||||
|
||||
This note drafts the short privacy line that `phase-16-play-share-tracking.md` **§3 (Option A,
|
||||
RESOLVED D5)** explicitly calls for: *"a short privacy-note line rather than a cookie wall."* It
|
||||
explains, in plain language, the anonymous first-party `anonId` token minted in wave 16.3.
|
||||
|
||||
## What the copy has to be honest about (from the spec §3)
|
||||
|
||||
The line must be accurate to the actual mechanism — no more, no less:
|
||||
|
||||
- A **random, first-party** token kept in this browser (`localStorage`), minted on first visit.
|
||||
- **No accounts, no personal data, no names, no email** — there is no identity model behind it.
|
||||
- **No cross-site tracking, no fingerprinting** — it never leaves as anything but an opaque token, and
|
||||
a third-party embed cannot read it (storage is partitioned).
|
||||
- **Listener-clearable** at any time (clear site data) — and clearing it simply mints a new one.
|
||||
- Used **only** to estimate *how many* listeners a track or release reached, in aggregate — never
|
||||
*who*. We label the number "listeners," not "people," because it over-counts by design (phone +
|
||||
laptop = two).
|
||||
|
||||
Voice target: the DeepDrft collective wrote this. Smart, plain, a little dry. No "we value your
|
||||
privacy," no "your trust matters to us," no marketing reassurance theatre. State the mechanism and
|
||||
stop. The honesty *is* the reassurance.
|
||||
|
||||
---
|
||||
|
||||
## Copy variants (pick one, or splice)
|
||||
|
||||
### Variant 1 — "the plain mechanism" (recommended)
|
||||
|
||||
> We keep a random tag in your browser so we can count how many people a track reaches — not who they
|
||||
> are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data
|
||||
> and the tag's gone.
|
||||
|
||||
**Why this one:** it leads with the mechanism ("a random tag in your browser"), names the exact limit
|
||||
("how many people... not who they are"), and closes on the listener's control. Reads like a person
|
||||
explaining a thing, not a policy. Shortest honest version that covers all five facts.
|
||||
|
||||
### Variant 2 — "the counter framing" (matches the 90s-hit-counter vibe)
|
||||
|
||||
> The play counter works off a random tag stored in your browser — enough to tell that a listen is a
|
||||
> distinct one, never enough to tell who you are. No login, no personal data, no tracking across other
|
||||
> sites. It's yours to clear whenever you like.
|
||||
|
||||
**Why this one:** ties the note directly to the Plays card it explains ("the play counter works
|
||||
off..."), which reads well if the line lives near the home stats. Slightly longer; "distinct one"
|
||||
nods honestly at the approximate-count nature.
|
||||
|
||||
### Variant 3 — "the one-liner" (footer-scale)
|
||||
|
||||
> We count listens with a random, anonymous tag in your browser — how many, never who. No accounts, no
|
||||
> cross-site tracking. Clear it any time.
|
||||
|
||||
**Why this one:** tightest. Fits a single footer line or a caption. Drops the explanatory rhythm of
|
||||
1 and 2 for density — best where space is the constraint and a fuller line would look heavy.
|
||||
|
||||
### Variant 4 — "the editorial aside" (for the /about page treatment)
|
||||
|
||||
> A note on the numbers: the play and listener counts you see run on a random tag we keep in your
|
||||
> browser — no account, no name, nothing that says who you are, nothing handed to anyone else. It tells
|
||||
> us a track reached *some number of* listeners, not which ones. Clear your site data and you're a
|
||||
> fresh tag; we'd never know the difference.
|
||||
|
||||
**Why this one:** written to sit inside the About page's editorial prose voice (the "Liner Notes"
|
||||
register), longer and more conversational. Only appropriate if the line lives on `/about`; too much
|
||||
for a footer.
|
||||
|
||||
---
|
||||
|
||||
## Placement options (also Daniel's call)
|
||||
|
||||
### A. Footer line (site-wide)
|
||||
|
||||
- **Pro:** always reachable, conventional home for a privacy note, set-and-forget, decouples the line
|
||||
from any one feature so it survives card/layout changes.
|
||||
- **Con:** low-salience — nobody reads footers; satisfies the "we disclosed it" bar more than the
|
||||
"listener actually understands" bar. A full sentence can look heavy in a footer; favours Variant 3.
|
||||
|
||||
### B. A line on the `/about` page
|
||||
|
||||
- **Pro:** `/about` is already the collective's voice explaining itself (the "presentation and proof of
|
||||
effort" page); a privacy aside fits its register and reaches the curious reader who'd actually care.
|
||||
Variant 4 was written for exactly this slot.
|
||||
- **Con:** lowest reach — only the fraction who open `/about` see it. Weak as the *sole* disclosure;
|
||||
better as the *expanded* version with a shorter pointer elsewhere.
|
||||
|
||||
### C. A small note near the home stats card
|
||||
|
||||
- **Pro:** highest contextual relevance — it sits right where the listener-count it explains is
|
||||
rendered, so the disclosure lands at the moment the number does. This is the most *honest* placement:
|
||||
you see the count and the "here's how we got it" in the same glance. Variant 2 is tuned for it.
|
||||
- **Con:** real estate is tight by the odometer cards; risks visual clutter on the hero. May need to be
|
||||
a small caption / info-affordance (a quiet "?" or "how we count" toggle) rather than always-on prose.
|
||||
And the card is the wave-16.5 capstone — this placement only exists once the card goes live, which
|
||||
couples the note's ship to the last wave.
|
||||
|
||||
### Recommendation
|
||||
|
||||
**Primary: A (footer line), using Variant 1 or 3 — ship it with the substrate, independent of the
|
||||
capstone card.** A footer line is the lowest-coupling, always-available disclosure and does not wait on
|
||||
the 16.5 card. Because the `anonId` token is minted in **wave 16.3** (already landed on dev), the
|
||||
disclosure arguably *should* exist before the capstone, and the footer is the only placement that
|
||||
doesn't depend on the card being live.
|
||||
|
||||
**Pair it with C (a quiet "how we count" affordance by the home stats) when the capstone card lands in
|
||||
16.5**, using Variant 2 — so the explanation also appears exactly where the number does. B (`/about`)
|
||||
is a nice-to-have third surface for the curious reader (Variant 4), not load-bearing.
|
||||
|
||||
This gives a tiered disclosure: an always-present footer line (legal/ethical floor), a contextual note
|
||||
at the point of display (honesty high-water mark), and an editorial expansion for the reader who wants
|
||||
it — without a cookie wall anywhere, per the spec.
|
||||
|
||||
**One sequencing flag for Daniel:** the token already ships (16.3 landed); the disclosure does not yet
|
||||
exist. If the footer line is the chosen floor, it can land independently of the 16.5 card and arguably
|
||||
should not trail it. Not urgent (first-party, no cross-site tracking, clearable — the lightest-touch
|
||||
case under GDPR/ePrivacy per spec §3), but worth a deliberate call rather than letting it ride to the
|
||||
capstone by default.
|
||||
|
||||
---
|
||||
|
||||
## What this note is NOT
|
||||
|
||||
- Not a privacy policy. If a full policy page is ever wanted, that is a separate, larger artifact — this
|
||||
line is the privacy-light disclosure the spec asked for, not a legal document.
|
||||
- Not a consent mechanism. Per spec §3 (D5), a random first-party id used for first-party aggregate
|
||||
counts is the lightest-touch case and is **deliberately not** behind a banner or wall. This note
|
||||
*informs*; it does not *gate*.
|
||||
- Not final. **Every variant above is a draft pending Daniel's wording sign-off and placement pick.**
|
||||
@@ -0,0 +1,441 @@
|
||||
# Phase 17 — Player-Bar Queue View
|
||||
|
||||
Status: **design spec — all 11 open questions resolved (Daniel, 2026-06-19).** Author: product-designer. Date: 2026-06-19.
|
||||
**Plan only — no code has been written by this doc.**
|
||||
|
||||
**Resolved (Daniel, 2026-06-19):** OQ1 → **Option A, conditional** (collapse/expand toggle *if* dynamic
|
||||
iframe resize is achievable in the embed snippet; **else fall back to Option B**, omit the button —
|
||||
feasibility call made during 17.3). OQ2 → **yes, both modes** (clicking a queued row jumps playback to
|
||||
that track in docked overlay *and* read-only embed; reuses `PlayRelease(Items, index)`). OQ3 + OQ11 →
|
||||
**the currently-playing track cannot be removed at all** (no "remove current" action; remove affordance
|
||||
suppressed on the current row; queue empties only organically when the current track ends with nothing
|
||||
queued after). OQ4 → **`MudDropContainer` for now** (pivot to a touch-viable mechanism later if mobile
|
||||
issues surface; C6 softened — touch-viability is a known risk, not a pre-ship blocker). OQ5 → **yes,
|
||||
Clear in the overlay header** — but Clear must **not** stop or remove the currently-playing track (it
|
||||
keeps playing and stays in the queue; only the other queued tracks are cleared). OQ6 → **fixed sensible
|
||||
height with internal scroll past N rows** (not grow-to-cap; affects `EmbedSnippetBuilder.ForRelease`).
|
||||
OQ7 → **Material icons for now** (`QueueMusic` / `PlaylistAdd`; bespoke `DDIcons` glyph not pursued in
|
||||
Phase 17). OQ8 → **pure append, confirmed** (add ≠ play; first add into a dormant queue leaves a coherent
|
||||
`CurrentIndex` via a small additive engine affordance, landing with wave 17.1). OQ9 → **exclude
|
||||
`StreamNowButton`** (no fixed track until one resolves). OQ10 → **deferred, confirmed** (cards get no
|
||||
Add-to-Queue in Phase 17; the deferred card work is captured in `TODO.md`).
|
||||
|
||||
This phase makes the play-queue **visible and manipulable**. Phase 11 (wave 11.F) built the queue
|
||||
*engine* (`IQueueService` / `QueueService`) and wired auto-advance, skip-prev/next, and release
|
||||
embeds — but the queue itself has **no UI**. A listener can start an album and skip through it, but
|
||||
cannot *see* what is next, reorder it, drop a track, or hand-build a queue. This phase surfaces it.
|
||||
|
||||
Cross-references (read before implementing):
|
||||
- `PLAN.md §17` — the concise phase entry.
|
||||
- `DeepDrftPublic.Client/Services/IQueueService.cs` / `QueueService.cs` — the engine this phase
|
||||
drives. Its docstrings **already anticipate** reorder + removal as additive members (see §1).
|
||||
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/AudioPlayerBar.razor[.cs]` — the bar that gains the
|
||||
Queue button + (Fixed) the embedded panel.
|
||||
- `DeepDrftPublic.Client/Controls/AudioPlayerBar/PlayerControls.razor` / `PlayerTransportZone.razor`
|
||||
— where transport buttons live; the Queue button sits relative to these.
|
||||
- `DeepDrftPublic.Client/Controls/AudioPlayerProvider.razor[.cs]` — cascades the queue (`IsFixed`).
|
||||
**Player + queue state stays here, at layout level** (project memory; do not move into pages).
|
||||
- `DeepDrftPublic.Client/Pages/FramePlayer.razor` + `Layout/EmbedLayout.razor` — the Fixed/embed
|
||||
surface; the iframe-host sizing constraint lives here (§4).
|
||||
- `DeepDrftPublic.Client/Controls/SharePopover.razor` / `WaveformVisualizerControlPopover.razor` —
|
||||
the established overlay/dismissal idioms to borrow for the non-Fixed overlay (§3).
|
||||
- `DeepDrftPublic.Client/Pages/CutDetail.razor`, `Controls/ReleaseHeroOverlay.razor`,
|
||||
`Controls/ReleaseGallery.razor`, `Controls/StreamNowButton.razor` — the play-affordance sites the
|
||||
"Add to Queue" button (§5) attaches beside.
|
||||
|
||||
---
|
||||
|
||||
## 0. State it inherits (verified 2026-06-19)
|
||||
|
||||
The queue engine is **mature and complete for orchestration**:
|
||||
|
||||
- `IQueueService` exposes `Items` (`IReadOnlyList<TrackDto>`), `CurrentIndex`, `Current`, `HasNext`,
|
||||
`HasPrevious`, `IsArmed`, the `QueueChanged` event, and commands `PlayRelease`, `Arm`, `Start`,
|
||||
`Enqueue`, `EnqueueRange`, `Next`, `Previous`, `Clear`. **There is no reorder or remove member
|
||||
yet** — but the interface docstring explicitly reserves them as additive ("reordering mutates
|
||||
`Items` and re-emits `QueueChanged`"). This phase adds them.
|
||||
- The queue is cascaded from `AudioPlayerProvider` as an `IsFixed` cascade alongside the player.
|
||||
- `AudioPlayerBar` already subscribes to `QueueChanged` to re-render skip affordances, and reads
|
||||
`HasNext`/`HasPrevious`. Adding a Queue button + panel is the same subscription, more rendering.
|
||||
- **Fixed** (`AudioPlayerBar.Fixed`) is the embed flag, set true by `FramePlayer` via `EmbedLayout`.
|
||||
In Fixed mode the bar is in normal flow (no minimize/spacer/clip); the host iframe is sized by the
|
||||
embed snippet (`EmbedSnippetBuilder`). Today a release embed arms a shared queue; a single-track
|
||||
embed leaves the queue empty.
|
||||
- **"Shared queue" today = a multi-track release** loaded via `PlayRelease` (docked) or `Arm`/`Start`
|
||||
(embed). The queue is *populated from one release's ordered tracks*. There is no hand-assembled
|
||||
multi-source queue yet — `Enqueue`/`EnqueueRange` exist on the interface but **no UI calls them**.
|
||||
|
||||
The gap this phase closes: the queue is a black box. You can drive it (skip) but not inspect or edit
|
||||
it. Item 4 ("Add to Queue") also activates the dormant `Enqueue` path — the first time the queue
|
||||
becomes a thing a listener *builds*, not just a release they *play*.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Make the queue a first-class, visible, editable object in the player bar — without moving any player
|
||||
or queue state out of the layout-level provider, and without changing the streaming/playback seam.
|
||||
|
||||
Three user-visible capabilities, gated by a single new **Queue** toggle button in the bar:
|
||||
|
||||
1. **See** the queue — an ordered list of what is queued, with the current track marked.
|
||||
2. **Edit** the queue (where editing is allowed) — drag-reorder and per-track removal.
|
||||
3. **Grow** the queue — an "Add to Queue" affordance everywhere there is a play button.
|
||||
|
||||
The two view modes (overlay vs. embedded panel) differ only in **presentation and edit-permission**,
|
||||
not in the data they read — both bind the same cascaded `IQueueService.Items` (project memory: *one
|
||||
source, multiple views*).
|
||||
|
||||
---
|
||||
|
||||
## 2. Constraints (hard)
|
||||
|
||||
- **C1 — Player/queue state stays at layout level.** The Queue view reads the cascaded
|
||||
`IQueueService`; it does not own or relocate queue state. No page-level player state. (Project
|
||||
memory.)
|
||||
- **C2 — No new playback semantics.** Reorder/remove mutate `Items` + `CurrentIndex` and re-emit
|
||||
`QueueChanged`; they must **not** restart, re-stream, or interrupt the currently-playing track
|
||||
(matching the engine's existing "the player stays a single-track device" posture). Reordering the
|
||||
*currently-playing* item or items around it must keep playback uninterrupted.
|
||||
- **C3 — Embed = read-only shared queue.** In Fixed (embed) mode, a shared (release) queue is
|
||||
**fixed-order and non-editable**: no drag, no remove. The panel is a *display* of the up-next list.
|
||||
- **C4 — The engine is extended additively, never reshaped.** New members (`Move`/`RemoveAt`) are
|
||||
added to `IQueueService`; existing members and their contracts are untouched. Consumers written
|
||||
against today's surface keep working (the interface's stated open/closed posture).
|
||||
- **C5 — Reorder/remove are interop-free state mutations.** Like `Enqueue`/`Arm`, they touch only the
|
||||
in-memory list + index + `QueueChanged`. They run safely during prerender. Only `PlayRelease`/
|
||||
`Start`/`Next`/`Previous` touch JS.
|
||||
- **C6 — Drag-and-drop should be touch-viable (softened — OQ4 resolved).** The public site's primary
|
||||
listening surface includes mobile (per `PLAN.md §1.7`). **Resolved (Daniel, 2026-06-19): ship with
|
||||
`MudDropContainer` now.** Touch-viability is **no longer a hard pre-ship blocker** — it is a known
|
||||
risk with a planned pivot path: if mobile/touch issues surface in practice, pivot to a touch-viable
|
||||
mechanism (pointer-interop reorder, matching `RadialKnob`'s `capturePointer`, or an up/down-arrow
|
||||
fallback) in a later pass. See OQ4 (resolved).
|
||||
- **C7 — Embedded panel must not break the iframe host.** Adding the panel in Fixed mode grows the
|
||||
player's intrinsic height; the embed snippet's iframe dimensions must account for it (§4, OQ6).
|
||||
|
||||
---
|
||||
|
||||
## 3. Item 1+2 — Queue button & non-Fixed (overlay) mode
|
||||
|
||||
### 3.1 The Queue button
|
||||
|
||||
A new icon button in the player bar, shown **only when a queue is loaded** (`Items.Count > 0`).
|
||||
Mirror the existing skip-affordance gating: with no queue (null) or empty queue, the button is
|
||||
absent, so the bar is byte-for-byte its pre-queue self for single-track play.
|
||||
|
||||
- **Placement (Daniel):** to the **left of the timestamps and below the other player control
|
||||
buttons.** Today `PlayerTransportZone` is a vertical `MudStack` of (transport-button row) over
|
||||
(`TimestampLabel`). The Queue button sits **between** those two — a second row below the
|
||||
play/skip controls and above the timestamp. That literally satisfies "below the control buttons,
|
||||
to the left of the timestamps."
|
||||
- **Glyph:** `Icons.Material.Filled.QueueMusic` (or a bespoke `DDIcons` queue glyph if Daniel wants
|
||||
the hand-rolled aesthetic — **OQ7**). `Color.Primary` to match the transport row.
|
||||
- **Active state:** when the overlay/panel is open, the button reads active (filled/highlighted),
|
||||
matching the visualizer popover idiom.
|
||||
|
||||
### 3.2 Non-Fixed overlay panel
|
||||
|
||||
When the bar is **not Fixed** (the docked public player), clicking Queue toggles a **screen-centered
|
||||
overlay** — borrow the exact idiom Phase 15 settled on for the visualizer controls: `MudOverlay`
|
||||
(`DarkBackground`, `Modal`), panel stops click propagation, scrim-click closes, drag-safe (a
|
||||
`position:fixed` drag capture sits above the scrim so a drag that ends outside the panel does not
|
||||
dismiss). **Not** a `MudPopover` anchored to the bar — a centered modal is the proven pattern here
|
||||
and avoids anchor math against a `position:fixed` dock.
|
||||
|
||||
- **Shape:** "mostly square" (Daniel) — a panel roughly `min(90vw, 520px)` square, scrollable track
|
||||
list inside. Chrome matches `NowPlayingCard` / the visualizer panel (square corners, lighter-navy,
|
||||
thin border) for family resemblance.
|
||||
- **Contents:** ordered list of `Items`. Each row: drag handle, track number/position, track
|
||||
name + artist, a now-playing marker on `Current` (index == `CurrentIndex`), a remove (×) button.
|
||||
Clicking a row's body jumps playback to that track (`PlayRelease(Items, thatIndex)` — reuses the
|
||||
existing "play from index" semantics; **OQ2** confirms this is desired vs. a dedicated jump).
|
||||
- **Reorder:** drag-and-drop reordering of rows → `IQueueService.Move(from, to)` (new member, §6).
|
||||
- **Remove:** per-row × → `IQueueService.RemoveAt(index)` (new member, §6).
|
||||
- **Header:** title ("Queue" / "Up Next"), optional "Clear" action (calls existing `Clear()`;
|
||||
**OQ5** — does Clear belong in the UI, and does it stop playback?).
|
||||
- **Empty state:** the button is hidden when empty, so the overlay is never opened empty by the
|
||||
button. But removal *can* empty a non-empty queue mid-session — define the terminal state (**OQ3**).
|
||||
|
||||
### 3.3 Borrowed precedent
|
||||
|
||||
The overlay shell, dismissal, and drag-safety are a **direct lift** of
|
||||
`WaveformVisualizerControlPopover` (Phase 15 §4). The "mostly square panel, family chrome" is the
|
||||
`NowPlayingCard` look. Reuse, don't reinvent.
|
||||
|
||||
---
|
||||
|
||||
## 4. Item 3 — Fixed (embedded) mode
|
||||
|
||||
When the bar **is** Fixed (the `/FramePlayer` iframe embed), the queue list is **always shown below
|
||||
the existing player-bar controls** — not a toggle overlay. It is part of the embed's standing layout.
|
||||
|
||||
- **Permissions (C3):** the embedded shared (release) queue is **fixed-order, non-editable** — no
|
||||
drag handles, no remove buttons. It is a read-only up-next display: ordered track list, current
|
||||
track marked, future tracks listed. Tapping a future row *may* jump to it (**OQ2** — even read-only
|
||||
queues could allow jump-to-track; reorder/remove are the forbidden ops, jump is arguably fine).
|
||||
- **Container resize (C7):** the Fixed bar gains vertical height for the panel. Two coupled changes:
|
||||
1. The `EmbedLayout` / `FramePlayer` Fixed bar renders the panel inline below the controls.
|
||||
2. `EmbedSnippetBuilder.ForRelease(...)` must mint an iframe tall enough to show the panel.
|
||||
`ForTrack(...)` stays at today's compact height (single-track embeds have no queue, so no
|
||||
panel). **This makes the two embed snippets diverge in height** — track-embed compact,
|
||||
release-embed taller. That is correct: only release embeds have a queue to show. (**OQ6** —
|
||||
fixed taller height for all release embeds, vs. a height that scales with track count up to a
|
||||
cap. Recommend: a fixed sensible height with the panel internally scrollable past N rows.)
|
||||
|
||||
### 4.1 The Queue button in embed mode (Daniel asked to clarify)
|
||||
|
||||
**Recommendation:** in Fixed mode the panel is always shown, so the Queue button is **not needed for
|
||||
toggling** — but rather than hide it, **repurpose it as a collapse/expand control** for the panel so a
|
||||
listener can reclaim the iframe space. Two viable readings, pick one:
|
||||
|
||||
- **Option A (resolved — preferred) — collapse toggle.** Queue button is present in Fixed mode and
|
||||
collapses/expands the always-default-open panel. Default = expanded. Gives the embedder's viewer
|
||||
control. Consistent button meaning across modes ("toggle the queue view").
|
||||
- **Option B (fallback) — omit the button in Fixed mode entirely.** Panel is always open, no toggle.
|
||||
Simpler, but the button's meaning then differs by mode (present+toggles-overlay when docked, absent
|
||||
when embedded), and a viewer can't reclaim space.
|
||||
|
||||
**OQ1 — RESOLVED (Daniel, 2026-06-19): Option A, *conditional on feasibility*.** Use Option A **if**
|
||||
the iframe can be dynamically resized appropriately for collapse/expand — e.g. an embed-snippet
|
||||
`postMessage` → host resize handshake so the host iframe shrinks when the panel collapses and grows
|
||||
when it expands. **Otherwise fall back to Option B** (omit the button, panel always open at fixed
|
||||
height). **A is preferred; B is the fallback; the deciding factor is whether dynamic iframe resize is
|
||||
achievable in the embed snippet.** This A-vs-B determination is **made during 17.3 implementation** —
|
||||
record it there. Note the interaction with OQ6 (embed panel height): a collapse/expand that does *not*
|
||||
resize the iframe (the earlier "iframe stays sized for expanded; collapsed just shows less" reading) is
|
||||
**not** Option A under this resolution — Option A requires the iframe to actually resize. A non-resizing
|
||||
collapse is cosmetic and does not let the viewer reclaim space, so it collapses into Option B's value.
|
||||
|
||||
---
|
||||
|
||||
## 5. Item 4 — "Add to Queue" affordance
|
||||
|
||||
An **"Add to Queue" icon button with a tooltip** beside **every play button for a release or track.**
|
||||
|
||||
- **Glyph + tooltip:** `Icons.Material.Filled.QueueMusic` / `PlaylistAdd` with `MudTooltip`
|
||||
"Add to queue" (track) / "Add release to queue" (release). (**OQ7** — Material vs. bespoke glyph.)
|
||||
- **Behavior:**
|
||||
- **Track context:** `IQueueService.Enqueue(track)` — appends without disturbing current playback
|
||||
(existing member, no UI calls it today; this lights it up).
|
||||
- **Release context:** `IQueueService.EnqueueRange(orderedTracks)` — appends the whole release's
|
||||
ordered track list (existing member).
|
||||
- **First-add semantics — RESOLVED (Daniel, 2026-06-19): pure append (option (a)).** Add-to-Queue is
|
||||
explicitly *not* play; keep the verbs distinct. If the queue is **empty/dormant** (`CurrentIndex ==
|
||||
-1`), the first add appends and **leaves a coherent `CurrentIndex`** (the small additive engine
|
||||
affordance) so the next play/skip behaves correctly — but **does not auto-play**. The listener still
|
||||
presses play to start. (Options (b) "first add also stages/sets current without auto-playing" and (c)
|
||||
"first add behaves like `PlayRelease`/play" are declined — they blur add vs. play.) **This engine
|
||||
tweak belongs with wave 17.1** (it is part of the `Enqueue`-into-empty index handling, alongside the
|
||||
`Move`/`RemoveAt` additions). See **OQ8 (resolved)**.
|
||||
|
||||
### 5.1 Where the button attaches (inventory)
|
||||
|
||||
Every site that currently has a play affordance:
|
||||
|
||||
| Site | Current play affordance | Add-to-Queue target |
|
||||
|---|---|---|
|
||||
| `CutDetail` header | "Play" `MudButton` (whole album) | release → `EnqueueRange` |
|
||||
| `CutDetail` track rows | per-row `PlayStateIcon` | track → `Enqueue` |
|
||||
| `SessionDetail` / `MixDetail` | `PlayContent` slot in `ReleaseHeroOverlay` (single track) | track → `Enqueue` |
|
||||
| `StreamNowButton` | random-track stream trigger | **edge case — OQ9** (random has no fixed track until resolved) |
|
||||
| `ReleaseGallery` cards | **none today** (cards are pure links) | **scope question — OQ10** |
|
||||
|
||||
- **`ReleaseGallery` cards have no play button today** — they navigate to detail. Adding an
|
||||
Add-to-Queue button there is *new* affordance, out of the literal brief ("anywhere there is a play
|
||||
button"). **OQ10 — RESOLVED (Daniel, 2026-06-19): deferred, confirmed.** Cards get **no**
|
||||
Add-to-Queue affordance in Phase 17. Scope item 4 to the detail-page play sites (rows 1–3 above)
|
||||
where a play button genuinely exists today. The deferred work — giving cards a play + Add-to-Queue
|
||||
affordance (a card-redesign question) — is captured as a `TODO.md` entry.
|
||||
- **`StreamNowButton`** is a "surprise me" trigger, not a fixed track — Add-to-Queue has no concrete
|
||||
item until a track resolves. Recommend: **exclude it** from item 4. **OQ9.**
|
||||
|
||||
So the **grounded scope** of item 4: the `CutDetail` header + track rows, and the Session/Mix hero
|
||||
play slot. That is "every play button that targets a known release or track."
|
||||
|
||||
---
|
||||
|
||||
## 6. Engine additions (the only `IQueueService` changes)
|
||||
|
||||
Two new members, both interop-free state mutations that re-emit `QueueChanged`:
|
||||
|
||||
- **`void Move(int fromIndex, int toIndex)`** — reorders `Items`; adjusts `CurrentIndex` so the
|
||||
*same track* stays current across the move (find current track's new position). No re-stream
|
||||
(C2). No-op on out-of-range or equal indices.
|
||||
- **`void RemoveAt(int index)`** — removes `Items[index]`.
|
||||
- Removing a track **after** current: just remove; `CurrentIndex` unchanged.
|
||||
- Removing a track **before** current: remove + decrement `CurrentIndex` (current track stays the
|
||||
same item, new lower index). Playback uninterrupted.
|
||||
- Removing the **current** track: **define** — recommend stop is *not* triggered (C2 says mutations
|
||||
don't touch playback), so the playing track keeps playing to its natural end even though it's no
|
||||
longer in the list, and `CurrentIndex` points at what is now at that slot (the next track) so the
|
||||
next auto-advance / skip is coherent. Edge: removing current when it's the last item → queue
|
||||
becomes shorter; auto-advance simply has nothing to advance to (existing behavior). **OQ3/OQ11.**
|
||||
- Removing the **last remaining** track → empty queue (`CurrentIndex = -1`, dormant). The Queue
|
||||
button disappears; if the overlay is open it closes (or shows empty state then closes). **OQ3.**
|
||||
|
||||
Both are **additive** (C4). Unit-testable against the existing `QueueServiceTests` fake-player
|
||||
harness with no container (the engine's stated design intent).
|
||||
|
||||
---
|
||||
|
||||
## 7. Use cases
|
||||
|
||||
- **UC1 — Inspect up-next (docked).** Listener playing an album opens the Queue overlay, sees the
|
||||
ordered remaining tracks with the current one marked, closes it. No edit.
|
||||
- **UC2 — Reorder (docked).** Listener drags track 5 above track 2; playback continues uninterrupted;
|
||||
the new order drives the next auto-advance.
|
||||
- **UC3 — Remove (docked).** Listener removes a track they don't want; if it was upcoming, it's gone
|
||||
from the up-next; if it was current, current keeps playing to natural end (per §6).
|
||||
- **UC4 — Build a queue (docked).** Listener on a Cut detail page clicks "Add to Queue" on three
|
||||
individual tracks across two albums, then opens the queue and plays from the top.
|
||||
- **UC5 — Embed up-next (Fixed).** A blog embeds a release; the iframe shows the player *and* the
|
||||
fixed-order track list below it; viewer sees what's coming, cannot reorder/remove; (Option A)
|
||||
collapses the panel to reclaim space.
|
||||
- **UC6 — Single-track embed unchanged.** A single-track embed shows no Queue button and no panel —
|
||||
the compact iframe is byte-for-byte today's.
|
||||
- **UC7 — Jump to track.** Listener clicks a queued row → playback jumps to that track and continues
|
||||
from there (if OQ2 = yes).
|
||||
|
||||
---
|
||||
|
||||
## 8. Acceptance criteria
|
||||
|
||||
- **AC1** — When `Items.Count == 0`, the Queue button is absent and the docked bar renders exactly as
|
||||
it does today (no layout shift, no empty panel).
|
||||
- **AC2** — When `Items.Count > 0` and not Fixed, the Queue button appears below the transport
|
||||
controls and to the left of the timestamp; clicking it opens a centered, mostly-square overlay
|
||||
listing the queue with the current track marked.
|
||||
- **AC3 (docked)** — Drag-reordering a row changes the queue order and is reflected in subsequent
|
||||
skip/auto-advance; the currently-playing track is **not** re-streamed or interrupted by any reorder.
|
||||
- **AC4 (docked)** — Removing an upcoming track removes it from the list and from auto-advance;
|
||||
removing a track before the current one keeps the same track playing; removing the current track
|
||||
does not stop playback (per §6 decision).
|
||||
- **AC5 (Fixed)** — In a release embed, the queue panel renders below the controls by default, shows
|
||||
the ordered tracks with the current marked, and exposes **no** drag handles or remove buttons.
|
||||
- **AC6 (Fixed)** — The release embed iframe (via `EmbedSnippetBuilder.ForRelease`) is tall enough to
|
||||
show the panel without clipping; the single-track embed (`ForTrack`) height is unchanged.
|
||||
- **AC7** — An "Add to Queue" button with tooltip appears beside the Cut header Play, each Cut track
|
||||
row's play, and the Session/Mix hero play; clicking it appends the track/release to the queue
|
||||
without disturbing the currently-playing track.
|
||||
- **AC8** — Add-to-Queue on a dormant (empty) queue leaves the queue populated with a coherent
|
||||
`CurrentIndex` such that the next play/skip behaves correctly (per OQ8 resolution).
|
||||
- **AC9** — `Move` and `RemoveAt` are additive to `IQueueService`; all existing `QueueServiceTests`
|
||||
pass unchanged; no existing member's behavior changes.
|
||||
- **AC10** — All queue-view rendering and the new engine mutations run during prerender without JS
|
||||
interop (no prerender exceptions); only playback transitions touch JS.
|
||||
|
||||
---
|
||||
|
||||
## 9. Test cases
|
||||
|
||||
Engine (`QueueServiceTests`, fake-player harness — no container):
|
||||
|
||||
- **T1** `Move(2, 0)` on a 4-item queue reorders correctly; `Items` reflects new order;
|
||||
`QueueChanged` fired once.
|
||||
- **T2** `Move` keeps the *current track* current: with `CurrentIndex` at track X, moving other items
|
||||
around it leaves `Current` == X; moving X itself updates `CurrentIndex` to X's new slot.
|
||||
- **T3** `Move` does not call the player's stream method (assert fake player saw no `SelectTrackStreaming`).
|
||||
- **T4** `RemoveAt` after current: `CurrentIndex` unchanged, item gone.
|
||||
- **T5** `RemoveAt` before current: `CurrentIndex` decremented, same track current.
|
||||
- **T6** `RemoveAt` of current: player not stopped (per §6); `CurrentIndex` resolves to the new
|
||||
occupant or `-1` if it was last.
|
||||
- **T7** `RemoveAt` of last remaining item → empty + dormant (`CurrentIndex == -1`, `Items` empty,
|
||||
`QueueChanged` fired).
|
||||
- **T8** `Move`/`RemoveAt` out-of-range / no-op cases do not throw and do not fire `QueueChanged`.
|
||||
- **T9** `Enqueue` into a dormant queue then play behaves per OQ8 resolution.
|
||||
- **T10** Reordering the currently-playing item to a new slot leaves auto-advance pointing at the
|
||||
correct next track when the current ends.
|
||||
|
||||
Component (if/when component test coverage is in scope — today none exists for the client, per
|
||||
`DeepDrftPublic.Client/CLAUDE.md`):
|
||||
|
||||
- **T11** Queue button absent when queue empty (render assertion).
|
||||
- **T12** Overlay opens/closes via button + scrim; a simulated drag that ends on the scrim does not
|
||||
dismiss.
|
||||
- **T13** Fixed-mode panel renders inline with no drag/remove controls.
|
||||
- **T14** `EmbedSnippetBuilder.ForRelease` height differs from `ForTrack` (unit test on the builder).
|
||||
|
||||
---
|
||||
|
||||
## 10. Open questions for Daniel
|
||||
|
||||
**All eleven resolved (Daniel, 2026-06-19): OQ1, OQ2, OQ3, OQ4, OQ5, OQ6, OQ7, OQ8, OQ9, OQ10, OQ11.**
|
||||
|
||||
- **OQ1 — Queue button in embed (Fixed) mode. RESOLVED → Option A, conditional.** Use Option A
|
||||
(collapse/expand toggle) **if** the iframe can be dynamically resized for collapse/expand (embed-snippet
|
||||
`postMessage` → host resize handshake); **else fall back to Option B** (omit the button). A preferred,
|
||||
B fallback; deciding factor = whether dynamic iframe resize is achievable in the embed snippet;
|
||||
determination made during **17.3** (see §4.1).
|
||||
- **OQ2 — Click-to-jump. RESOLVED → yes, both modes.** Clicking a queued row jumps playback to that
|
||||
track in **both** the docked overlay and the read-only embed, reusing `PlayRelease(Items, index)`.
|
||||
Jump is not a forbidden edit — only reorder/remove are — so the read-only embed allows it too.
|
||||
- **OQ3 — Terminal/empty states. RESOLVED (jointly with OQ11) → the currently-playing track cannot be
|
||||
removed at all.** There is no "remove current" user action: the remove (×) affordance is **suppressed
|
||||
on the current row** in the overlay. The queue therefore empties only **organically** — when the
|
||||
current track ends and nothing is queued after it. There is no mid-session "removed the current track"
|
||||
state to design for, because the user cannot reach it. (The road not taken — "show an empty state then
|
||||
auto-close on user-driven empty" — is moot: user removal can only ever target non-current rows, so the
|
||||
user cannot remove their way to an empty queue while a track is playing.) The engine's `RemoveAt`-of-
|
||||
current behavior built in 17.1 remains as **defensive engine behavior but is UI-unreachable**.
|
||||
- **OQ4 — Drag-and-drop mechanism & touch. RESOLVED → `MudDropContainer` for now.** Ship with
|
||||
MudBlazor's `MudDropContainer`. If mobile/touch issues surface in practice, pivot to a touch-viable
|
||||
mechanism later (pointer-based interop reorder matching `RadialKnob`'s `capturePointer`, or an
|
||||
up/down-arrow fallback). C6 softened: touch-viability is a known risk with a planned pivot path, **not**
|
||||
a hard pre-ship blocker (see §2 C6).
|
||||
- **OQ5 — "Clear queue" in the UI. RESOLVED → yes, in the overlay header.** Include a Clear action in
|
||||
the overlay header. Clear empties the up-next but **must NOT stop the currently-playing track and must
|
||||
NOT remove it** — the current track keeps playing and stays in the queue; only the *other* queued
|
||||
tracks are cleared. (Note: this is a tighter contract than the engine's bare `Clear()`, which empties
|
||||
the whole list — the UI's Clear preserves the current track. This belongs in the 17.2 overlay wiring.)
|
||||
- **OQ6 — Embed panel height. RESOLVED → fixed sensible height with internal scroll past N rows.** Not
|
||||
grow-to-cap. Especially important for the embed. Affects `EmbedSnippetBuilder.ForRelease`. (Road not
|
||||
taken: a height that grows with track count up to a cap — declined in favor of a fixed height with the
|
||||
panel internally scrollable.)
|
||||
- **OQ7 — Glyph. RESOLVED → Material icons for now.** Use `Icons.Material.Filled.QueueMusic` /
|
||||
`PlaylistAdd`. A bespoke hand-rolled `DDIcons` queue glyph (gas-lamp/lava-lamp family aesthetic) is
|
||||
**not pursued in Phase 17** — left open for a later aesthetic pass.
|
||||
- **OQ8 — Add-to-Queue into a dormant (empty) queue. RESOLVED → pure append.** Add ≠ play; first add
|
||||
into a dormant queue appends and leaves a coherent `CurrentIndex` (small additive engine affordance)
|
||||
so the next play/skip behaves correctly, but does **not** auto-play. The engine tweak lands with wave
|
||||
**17.1**. (first-add-stages and first-add-plays both declined — see §5.)
|
||||
- **OQ9 — `StreamNowButton`. RESOLVED → exclude.** `StreamNowButton` is excluded from the Add-to-Queue
|
||||
affordance — it is a "surprise me" trigger with no fixed track until one resolves.
|
||||
- **OQ10 — `ReleaseGallery` cards. RESOLVED → deferred, confirmed.** Browse-grid cards get **no**
|
||||
Add-to-Queue affordance in Phase 17. Item 4 is scoped to the detail-page play sites (Cut header, Cut
|
||||
track rows, Session/Mix hero). The deferred card work (play + Add-to-Queue, a card-redesign question)
|
||||
is captured in `TODO.md`.
|
||||
- **OQ11 — Removing the current track. RESOLVED (jointly with OQ3) → the current track cannot be removed
|
||||
from the UI at all.** Rather than choosing between "keep playing to natural end" and "skip to next," the
|
||||
user-facing answer is that there is **no remove-current action** — the × is suppressed on the current
|
||||
row (see OQ3). Both candidate behaviors are therefore UI-unreachable; the engine's `RemoveAt`-of-current
|
||||
path remains only as defensive engine behavior.
|
||||
|
||||
---
|
||||
|
||||
## 11. Wave decomposition (implementation sequencing)
|
||||
|
||||
Three waves; **17.1 is the load-bearing prerequisite** (engine + the shared queue-list view that both
|
||||
modes render). 17.2 and 17.3 then hang off it and are largely parallel.
|
||||
|
||||
- **17.1 — Engine additions + shared queue-list view.** Add `Move`/`RemoveAt` to `IQueueService`/
|
||||
`QueueService` (+ `QueueServiceTests`). **Plus the OQ8 affordance:** `Enqueue` into a dormant/empty
|
||||
queue leaves a coherent `CurrentIndex` (pure append, no auto-play) so the next play/skip is correct.
|
||||
Build a single presentational `QueueList` component that renders `Items` with the current marked and
|
||||
takes an `Editable` flag (drag/remove on when true; reorder via `MudDropContainer` per OQ4). This is
|
||||
the *one source* both modes consume. **Cold-start. Gates 17.2 + 17.3.** No bar changes yet.
|
||||
- **17.2 — Non-Fixed overlay.** Queue button in the bar (gated on `Items.Count > 0`, placed per
|
||||
§3.1), the centered overlay borrowing the visualizer-popover idiom, hosting `QueueList Editable`.
|
||||
Reorder/remove/jump wired. **Depends on 17.1.**
|
||||
- **17.3 — Fixed embed panel + snippet resize + Add-to-Queue.** Inline `QueueList` (non-editable)
|
||||
below the Fixed bar; **the OQ1 feasibility determination is made here** — implement Option A
|
||||
(collapse/expand button + iframe resize handshake) **if** dynamic iframe resize proves achievable in
|
||||
the embed snippet, else fall back to Option B (omit the button); `EmbedSnippetBuilder.ForRelease`
|
||||
height bump; the Add-to-Queue buttons at the detail-page play sites (§5.1 grounded scope — **cards
|
||||
excluded per OQ10**). **Depends on 17.1.**
|
||||
(Add-to-Queue could split into its own wave 17.4 if Daniel wants it shipped independently — it only
|
||||
needs the existing `Enqueue`/`EnqueueRange`, not 17.1's new members.)
|
||||
|
||||
**Dependency shape:** `17.1 → {17.2, 17.3}`. The cold-start item is 17.1. Most open questions
|
||||
(OQ1–OQ11) must be resolved before 17.2/17.3 land, but 17.1 can begin immediately — the engine
|
||||
contract for `Move`/`RemoveAt` is settled (§6) and independent of the UI decisions.
|
||||
Reference in New Issue
Block a user