diff --git a/.gitignore b/.gitignore index 7c34dae..088129f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ Helpers/OAuthCredentials.cs *.suo .vs/ *.DotSettings.user + +# Confidential business files (gitignored, not committed) +MONETIZATION.md +MARCOM.md diff --git a/HANDOFF.md b/HANDOFF.md index d8d4974..d1db8f9 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -5,11 +5,44 @@ > a problem. Conventions: [`schema.md`](schema.md). Rewrite this file at session > end, compaction, or any interruption. -## Session state (last updated: 2026-08-15) +## Session state (last updated: 2026-08-16) -- **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 24 polish batch** + the **game - audio bar always-visible change** + **TASK 25 (master limiter)** are committed + pushed. Local - branches `social-bar`/`webcam-validation` untouched (no secrets). +- **Branch:** `main`, tracking `origin/main`. Working tree: the **TASK 5 reusable-stream slice** is + committed + pushed (details below). Local branches `social-bar`/`webcam-validation` untouched (no secrets). +- **TASK 5 — REUSABLE STREAM + HEALTH BANNER — SHIPPED 2026-08-16 (items 1–3).** + - **Items 1–2 (reusable stream):** `_rtmpUrlProvider` now yields a real RTMP URL. + `YouTubeStreamService` gains `GetOrCreateReusableStreamAsync` (lists `liveStreams?mine=true`, + reuses the existing `cdn.isReusable` stream, inserts once on first use with + `resolution=variable`/`frameRate=variable`); `CreateBroadcast(..., streamId)` binds at insert via + `boundStreamId` + the one-click v3 flags (`enableMonitorStream=false`, `latencyPreference=low`). + Cached via `LayoutStore` Settings (`SaveReusableStream`/`LoadReusableStream`). **Go-live order + changed** (the pump reads the URL once at start — `FramePump.StartAsync`): `BeginGoLive` → + `PrepareAndStartLiveAsync` = ensure stream → create+bind broadcast → THEN start the pump. + - **Item 3 (report-by-exception health):** `GetStreamHealthAsync(streamId)` polls + `liveStreams?part=status`; the pure `Services/StreamHealthReporter.BannerFor` decides (null text on + good/ok/noData/info-only; warning/error issue → its type text, error beats warning). The VM polls + every **30s while live** (`_healthPollTimer`, first poll right after go-live, stopped on End/Error + via `UpdateLiveVisuals`; failures log-only). UI = full-width banner strip under the top bar, + `HealthIssueBanner` + `HealthIssueBackground` (amber `#b8860b` warning / dark red `#8f1f1f` error), + hidden by `NotNullToVis`; cleared in `ResetHealth`. + - **Tests (8 new this branch, 207 total, 0 warnings):** 3 service units (parse, good→no issues, + no-session→null) + 4 `StreamHealthReporterTests` + the ONE integration test + `GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error` (real service JSON parse + → real reporter: good → no banner, error issue → banner text + error color). +- **CONFIDENTIAL files created (gitignored):** `MONETIZATION.md` (pricing, billing research, unlock + mechanics) and `MARCOM.md` (launch marketing strategy, positioning, platform strategy). These are + NOT committed to the public repo. `Helpers/OAuthCredentials.cs` was already gitignored. +- **TASK 5 items 6-7 scoped (not built):** visibility unlock (remove temporary "always Private" enforcement) + and full broadcast form (Core + Advanced tabs with all YouTube API-supported fields). These are the + next technical tasks after live chat (item 4) and error handling (item 5). +- **TASK 23 trimmed** to technical scope only (billing decision, unlock mechanism, bug report, alerts gating). + Business details moved to `MONETIZATION.md`. +- **TASK 26 added** — marcom/launch kit. Business details moved to `MARCOM.md`. Queued after all v1 features. +- **Backlog updated** — removed v0.3 (stream scheduling, not needed for casual streamers). Multi-destination + restreaming noted as "congrats, you're ready for OBS" moment. +- **Resume point (next branch):** **TASK 5 item 4 — live chat** (`liveChat/messages` poll, right-panel + render, Super Chat + membership badges). Then item 5 (error handling), item 6 (visibility unlock), + item 7 (full broadcast form), then TASK 23 (billing + unlock + support). - **TASK 25 — MASTER LIMITER — COMMITTED + PUSHED 2026-08-15.** Queued from the TRAX discussion: the live mix summed mic + loopback with no ceiling, so hot gains could pass 0 dBFS and clip the AAC encode. New pure `Services/Audio/MasterLimiter.cs` (−1 dBFS ceiling, instant attack per frame, @@ -38,13 +71,63 @@ - **Secrets scrubbed from git history.** The DO token + passwords were purged via `git filter-branch` + `git gc --prune=now --aggressive` (all-refs scan = 0 hits); values still exist in chat — keep treating as **compromised**; rotate the DO API token. -- **Shipped, all pushed:** TASK 24 polish batch, TASK 21 (creator-hub About, `3d92bd0`), TASK 8 - (meter +10 dB), TASK 4 ship step 7 (one-click go-live + private-only), TASK 9 audio milestone - (`6d71ace`), secrets cleanup (`f2f6401`), monetization docs (`86fcd86`). -- **Resume point (next branch):** **TASK 5** — reusable stream → `_rtmpUrlProvider` (the last blocker - before go-live actually encodes + pushes). Then TASK 23 (billing + unlock + support). - Optional, not queued: rewriting the healed entry's `ProfileUrl` to - `https://mastodon.llamachile.tube/@gramps` (user must say the word). +- **Shipped, all pushed:** TASK 5 items 1–3 (reusable stream + health banner, 2026-08-16), TASK 25 + master limiter, TASK 24 polish batch, TASK 21 (creator-hub About, `3d92bd0`), TASK 8 (meter +10 dB), + TASK 4 ship step 7 (one-click go-live + private-only), TASK 9 audio milestone (`6d71ace`), secrets + cleanup (`f2f6401`), monetization docs (`86fcd86`). + +## TASK 5 — reusable stream + health banner 2026-08-16 (what changed, items 1–3) + +The recorded resume point: the "last blocker" was that go-live ran the visual flow but never pushed — +`_rtmpUrlProvider` returned null, so `FramePump.StartAsync` skipped the encoder entirely. + +- **`Services/YouTubeStreamService.cs`:** new `GetOrCreateReusableStreamAsync()` lists + `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once per channel + only on first use (`cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`) and + returning a `ReusableStream(Id, IngestionAddress, StreamName)` record (`RtmpUrl` = + `ingestionAddress/streamName`). `CreateBroadcast` takes an optional `streamId` and binds at insert + via `contentDetails.boundStreamId` (no second bind round-trip) and now always sends the full + one-click v3 flag set (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`). + The old per-broadcast `BindStream` (throwaway 1080p/60fps stream + `contentDetails.streamId`) is + **gone**. +- **`Services/LayoutStore.cs`:** `SaveReusableStream`/`LoadReusableStream` — the Settings key/value + table caches the stream id/address/name so the pump has its RTMP URL at startup, no round-trip. +- **`ViewModels/MainViewModel.cs`:** `_rtmpUrlProvider` returns `_reusableStreamUrl` (loaded from the + cache in the ctor, set fresh on go-live). `BeginGoLive` → `PrepareAndStartLiveAsync`: ensure the + stream → cache it → create the broadcast bound to it → **then** `_framePump.StartAsync()`. The + ordering matters because the pump reads `_encoderOptions()` once at startup. +- **Tests (6 new, 199 total, 0 warnings):** `GetOrCreateReusableStreamAsync_Reuses_Existing_Reusable_Stream` + (list path, no POST), `..._Creates_When_None_Exists` (insert path with variable/isReusable), + `CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert` (boundStreamId + new v3 flags), + `..._Without_Session_Returns_Null`, `ReusableStream_Cache_RoundTrips`, and the ONE integration test + `FramePumpTests.ReusableStream_Url_From_Service_Feeds_Encoder_Startup` (real service + real pump + + hermetic HTTP: the reusable stream's URL lands in `EncoderOptions.RtmpUrl` and the encoder starts). + +**Out of scope this branch (next branches):** TASK 5 item 4 live chat, item 5 the YouTube error-code +mappings, and the design's bottom-strip YouTube logo/green-red dot (clickable → dialog). + +### Item 3 — report-by-exception health banner (same session, second branch-worth of scope) + +- **`Services/YouTubeStreamService.cs`:** `GetStreamHealthAsync(streamId)` polls + `liveStreams?part=status&id={id}` → `StreamHealth` with parsed `healthStatus` (`good|ok|bad|noData`) + + `configurationIssues[]` (severity `info|warning|error` + type). The old `GetStreamHealth(broadcastId)` + (wrong endpoint — `liveBroadcasts.lifeCycleStatus` — zero callers) is **gone**. +- **`Services/StreamHealthReporter.cs` (new, pure):** `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — + null text on good/ok/noData/info-only; the first warning/error issue produces its type text + (comma-joined, blank types dropped); error beats warning for color. +- **`ViewModels/MainViewModel.cs`:** `_reusableStream` (the record, not just the URL) is stashed by + `PrepareAndStartLiveAsync` + loaded from the cache in the ctor; a 30s `DispatcherTimer` + (`_healthPollTimer`) polls while live — first poll fires right after the pump starts, the tick + handler fire-and-forgets `PollHealthAsync()` (fully try/caught, failures log-only), and + `UpdateLiveVisuals`' offline/error branch stops the timer. `ApplyHealthIssue` sets + `HealthIssueBanner` + `HealthIssueBackground` from the reporter; `ResetHealth` clears both. +- **`MainWindow.xaml`:** a full-width banner strip in its own window-grid row (below the top bar, + above the content) bound via `NotNullToVis` to `HealthIssueBanner`, background to + `HealthIssueBackground` (amber `#b8860b` warning / dark red `#8f1f1f` error); rows shifted + (content → row 2, footer → row 3). +- **Tests (8 new this branch, 207 total, 0 warnings):** 3 service units + 4 `StreamHealthReporterTests` + + the ONE integration test `GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error` + (real service JSON parse → real reporter: good → no banner, error issue → banner text + error color). ## TASK 24 — the 8-issue polish batch — SHIPPED 2026-08-15 (what changed) diff --git a/MainWindow.xaml b/MainWindow.xaml index 570db6d..0af4d8d 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -74,6 +74,7 @@ + @@ -136,7 +137,16 @@ - + + + + + + @@ -869,7 +879,7 @@ - + diff --git a/Models/StreamConfig.cs b/Models/StreamConfig.cs index 23e9dc2..e0ddd6d 100644 --- a/Models/StreamConfig.cs +++ b/Models/StreamConfig.cs @@ -29,7 +29,24 @@ public class StreamHealth public TimeSpan StreamDuration { get; set; } public string? LastError { get; set; } - // YouTube-specific - public string? HealthStatus { get; set; } // "good", "bad", "ok" + // YouTube-specific (liveStreams.status) + public string? HealthStatus { get; set; } // "good", "ok", "bad", "noData" public string? HealthMessage { get; set; } + + // Report-by-exception (TASK 5 item 3): configurationIssues[] — the banner + // surfaces only warning/error entries. + public List ConfigurationIssues { get; } = new(); +} + +public enum StreamIssueSeverity +{ + Info, + Warning, + Error +} + +public class StreamConfigurationIssue +{ + public StreamIssueSeverity Severity { get; set; } + public string? Type { get; set; } } diff --git a/Models/YouTube.cs b/Models/YouTube.cs index 48cc432..240202c 100644 --- a/Models/YouTube.cs +++ b/Models/YouTube.cs @@ -10,6 +10,14 @@ public class YouTubeChannel public DateTime TokenExpiry { get; set; } } +/// The channel's reusable live stream (TASK 5): created once, cached, +/// rebound to every broadcast. The RTMP URL the encoder pushes to is the +/// ingestion address + "/" + stream name. +public record ReusableStream(string Id, string IngestionAddress, string StreamName) +{ + public string RtmpUrl => $"{IngestionAddress}/{StreamName}"; +} + public class ChatMessage { public string Id { get; init; } = Guid.NewGuid().ToString(); diff --git a/Services/LayoutStore.cs b/Services/LayoutStore.cs index 553b77d..f0c241d 100644 --- a/Services/LayoutStore.cs +++ b/Services/LayoutStore.cs @@ -615,6 +615,35 @@ public class LayoutStore : IDisposable insert.ExecuteNonQuery(); } + /// The cached reusable stream (TASK 5), or null if never created. + /// Survives restarts so the pump has its RTMP URL the moment go-live starts + /// instead of waiting on the YouTube round-trip. + public ReusableStream? LoadReusableStream() + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "SELECT Value FROM Settings WHERE Key = 'ReusableStream';"; + var value = cmd.ExecuteScalar() as string; + if (string.IsNullOrWhiteSpace(value)) return null; + + var parts = value.Split('\u001F'); + return parts.Length == 3 + ? new ReusableStream(parts[0], parts[1], parts[2]) + : null; + } + + public void SaveReusableStream(ReusableStream? stream) + { + using var cmd = _connection.CreateCommand(); + cmd.CommandText = "DELETE FROM Settings WHERE Key = 'ReusableStream';"; + cmd.ExecuteNonQuery(); + if (stream == null) return; + + using var insert = _connection.CreateCommand(); + insert.CommandText = "INSERT INTO Settings (Key, Value) VALUES ('ReusableStream', $value);"; + insert.Parameters.AddWithValue("$value", $"{stream.Id}\u001F{stream.IngestionAddress}\u001F{stream.StreamName}"); + insert.ExecuteNonQuery(); + } + public void Save(IEnumerable scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null) { using var tx = _connection.BeginTransaction(); diff --git a/Services/StreamHealthReporter.cs b/Services/StreamHealthReporter.cs new file mode 100644 index 0000000..e8072b9 --- /dev/null +++ b/Services/StreamHealthReporter.cs @@ -0,0 +1,26 @@ +using ytLive.Models; + +namespace ytLive.Services; + +/// Report-by-exception (TASK 5 item 3): turns liveStreams +/// configuration issues into a banner. good/ok/noData and info-only issues +/// produce no banner; the first warning/error issue produces its text, colored +/// by the worst severity (error beats warning). Pure — fully testable. +public static class StreamHealthReporter +{ + public static HealthIssueReport BannerFor(IEnumerable issues) + { + var reportable = issues + .Where(i => i.Severity is StreamIssueSeverity.Warning or StreamIssueSeverity.Error) + .ToList(); + if (reportable.Count == 0) return new HealthIssueReport(null, false); + + var isError = reportable.Any(i => i.Severity == StreamIssueSeverity.Error); + return new HealthIssueReport( + string.Join(", ", reportable.Select(i => i.Type).Where(t => !string.IsNullOrWhiteSpace(t))), + isError); + } +} + +/// null Text = no banner. IsError colors it: error → app red, else amber. +public record HealthIssueReport(string? Text, bool IsError); diff --git a/Services/YouTubeStreamService.cs b/Services/YouTubeStreamService.cs index 50f07c9..100eeaa 100644 --- a/Services/YouTubeStreamService.cs +++ b/Services/YouTubeStreamService.cs @@ -29,7 +29,7 @@ public class YouTubeStreamService return true; } - public async Task CreateBroadcast(string title, string description, DateTime scheduledStartTime) + public async Task CreateBroadcast(string title, string description, DateTime scheduledStartTime, string? streamId = null) { if (!await EnsureToken()) return null; @@ -49,11 +49,10 @@ public class YouTubeStreamService privacyStatus = "private", selfDeclaredMadeForKids = false }, - contentDetails = new - { - enableAutoStart = true, - enableAutoStop = true - } + // One-click go-live (TASK 5 design decision 1): auto start/stop with + // no monitor stream and low latency. A reusable stream, when given, + // binds here (boundStreamId) so no second bind round-trip is needed. + contentDetails = BuildContentDetails(streamId) }; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); @@ -67,66 +66,128 @@ public class YouTubeStreamService return data.GetProperty("id").GetString(); } - public async Task BindStream(string broadcastId, string streamKey) + private static Dictionary BuildContentDetails(string? streamId) + { + var details = new Dictionary + { + ["enableAutoStart"] = true, + ["enableAutoStop"] = true, + ["enableMonitorStream"] = false, + ["latencyPreference"] = "low", + }; + if (streamId != null) details["boundStreamId"] = streamId; + return details; + } + + /// Returns the channel's reusable stream (TASK 5 design decision 2): + /// lists existing streams first and reuses the one with cdn.isReusable=true, + /// creating it with variable resolution/frame rate on first use. Binding to a + /// broadcast happens at broadcast insert (boundStreamId), so one reusable + /// stream serves every broadcast without recreation. + public async Task GetOrCreateReusableStreamAsync() { if (!await EnsureToken()) return null; - // Create stream resource + _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); + var listResponse = await _http.GetAsync( + $"{ApiBase}/liveStreams?mine=true&part=snippet,cdn,status"); + if (!listResponse.IsSuccessStatusCode) return null; + + var listJson = JsonSerializer.Deserialize( + await listResponse.Content.ReadAsStringAsync()); + if (listJson.TryGetProperty("items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + if (item.TryGetProperty("cdn", out var cdn) && + cdn.TryGetProperty("isReusable", out var reusable) && + reusable.GetBoolean()) + { + var parsed = ParseStream(item); + if (parsed != null) return parsed; + } + } + } + var stream = new { - snippet = new { title = $"stream-{broadcastId}" }, - contentDetails = new + snippet = new { title = "ytLlive Reusable Stream" }, + cdn = new { ingestionType = "rtmp", - frameRate = "60fps", - resolution = "1080p" + resolution = "variable", + frameRate = "variable", + isReusable = true } }; - - _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); var response = await _http.PostAsJsonAsync( - $"{ApiBase}/liveStreams?part=snippet,contentDetails", stream); - + $"{ApiBase}/liveStreams?part=snippet,cdn", stream); if (!response.IsSuccessStatusCode) return null; - var json = await response.Content.ReadAsStringAsync(); - var data = JsonSerializer.Deserialize(json); - var streamId = data.GetProperty("id").GetString(); - - // Bind stream to broadcast - var bindResponse = await _http.PutAsJsonAsync( - $"{ApiBase}/liveBroadcasts?part=id,contentDetails&id={broadcastId}", - new { contentDetails = new { streamId } }); - - return bindResponse.IsSuccessStatusCode ? streamId : null; + var json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + return ParseStream(json); } - public async Task GetStreamHealth(string broadcastId) + private static ReusableStream? ParseStream(JsonElement item) + { + if (!item.TryGetProperty("id", out var id) || + !item.TryGetProperty("cdn", out var cdn) || + !cdn.TryGetProperty("ingestionInfo", out var info)) + { + return null; + } + + var streamId = id.GetString(); + var address = info.TryGetProperty("ingestionAddress", out var addr) ? addr.GetString() : null; + var name = info.TryGetProperty("streamName", out var nameEl) ? nameEl.GetString() : null; + if (string.IsNullOrWhiteSpace(streamId) || string.IsNullOrWhiteSpace(address) || string.IsNullOrWhiteSpace(name)) + return null; + + return new ReusableStream(streamId, address, name); + } + + /// Polls the reusable stream's health (TASK 5 item 3) via + /// liveStreams.status — report-by-exception: good/ok/noData yield an empty + /// issue list, warning/error entries in configurationIssues[] drive the + /// banner. Null on failure or an empty response, never a throw. + public async Task GetStreamHealthAsync(string streamId) { if (!await EnsureToken()) return null; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); var response = await _http.GetAsync( - $"{ApiBase}/liveBroadcasts?part=contentDetails,status&id={broadcastId}"); - + $"{ApiBase}/liveStreams?part=status&id={streamId}"); if (!response.IsSuccessStatusCode) return null; - var json = await response.Content.ReadAsStringAsync(); - var data = JsonSerializer.Deserialize(json); - var items = data.GetProperty("items"); + var json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + var items = json.GetProperty("items"); if (items.GetArrayLength() == 0) return null; - var status = items[0].GetProperty("status").GetProperty("lifeCycleStatus").GetString(); - return new StreamHealth + var status = items[0].GetProperty("status"); + var health = new StreamHealth { - HealthStatus = status switch - { - "live" => "good", - "ready" => "ok", - "created" => "ok", - _ => "bad" - }, - HealthMessage = status + HealthStatus = status.TryGetProperty("healthStatus", out var healthStatus) + ? healthStatus.GetString() + : null, }; + if (status.TryGetProperty("configurationIssues", out var issues)) + { + foreach (var issue in issues.EnumerateArray()) + { + var severity = issue.TryGetProperty("severity", out var sev) ? sev.GetString() : null; + var type = issue.TryGetProperty("type", out var t) ? t.GetString() : null; + health.ConfigurationIssues.Add(new StreamConfigurationIssue + { + Severity = severity switch + { + "error" => StreamIssueSeverity.Error, + "warning" => StreamIssueSeverity.Warning, + _ => StreamIssueSeverity.Info, + }, + Type = type, + }); + } + } + return health; } } diff --git a/Services/index.md b/Services/index.md index e40d65b..1b2e63b 100644 --- a/Services/index.md +++ b/Services/index.md @@ -6,9 +6,10 @@ External-facing logic: YouTube API, persistence. See | File | Purpose | |------|---------| | `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) | -| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1; injectable `HttpClient? http = null` ctor seam so tests can fake the API and assert the forced privacy). **Not yet switched to the `variable` reusable stream** | +| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 5, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 5 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` | +| `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 5 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color | | `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` | -| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.EnforceBackdropPolicy` re-normalizes on every load) | +| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.EnforceBackdropPolicy` re-normalizes on every load). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 5 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) | | `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam | | `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device | | `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) | diff --git a/TASKS.md b/TASKS.md index 0864605..1491bb7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -562,13 +562,15 @@ the validator → persisted), compositor bar overlay (top/bottom + above-flash), **Goal:** Create/bind broadcasts, monitor YouTube-side stream health — the v3 way. -### Status: ⏳ Not started — runs AFTER the TASK 24 polish batch (creator's pick, 2026-08-15); `_currentBroadcastId` is already stashed from TASK 4 for its bind/transition work +### Status: ⏳ In progress — items 1–3 SHIPPED (reusable stream 2026-08-16; report-by-exception health 2026-08-16); items 4–7 still open (each its own branch/PR) -1. ☐ Broadcast creation — title/description/privacy/scheduledStartTime via API, with the v3 flags above -2. ☐ Reusable stream — create once, cache + reuse; bind to broadcast -3. ☐ Health monitoring — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error +1. ☑ **Broadcast creation** — title/description/privacy/scheduledStartTime via API, with the v3 flags above (SHIPPED: `CreateBroadcast` sends `enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`, `selfDeclaredMadeForKids=false`) +2. ☑ **Reusable stream** — create once, cache + reuse; bind to broadcast (SHIPPED: `GetOrCreateReusableStreamAsync` lists-then-inserts the `variable`/`isReusable` stream, cached via `LayoutStore` Settings, bound at broadcast insert via `boundStreamId`; `_rtmpUrlProvider` yields the ingest URL so go-live actually encodes + pushes) +3. ☑ **Health monitoring** — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error (SHIPPED: `GetStreamHealthAsync(streamId)` 30s while live; pure `StreamHealthReporter.BannerFor` = report-by-exception; banner strip under the top bar, amber warning / dark-red error, via `HealthIssueBanner`/`HealthIssueBackground`; poll failures log-only; ONE integration test `GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error`) 4. ☐ Live chat — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges 5. ☐ Error handling — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed` +6. ☐ **Visibility picker** — remove temporary "always Private" enforcement (shipped as test-only; now unlocked for v1). User picks Private/Unlisted/Public from the go-live dialog. Trivial: remove the hardcoded override in `YouTubeStreamService.CreateBroadcast` (currently `privacyStatus = "private"` regardless of dialog selection) +7. ☐ **Full broadcast form** — expose all YouTube API-supported fields in the go-live dialog. Core tab: title, description, visibility, made-for-kids, schedule (start + optional end). Advanced tab (expandable, sane defaults): latency (Normal/Low/Ultra-Low), DVR, embed, record-from-start, projection (rectangular/360°), closed captions, auto-start, auto-stop, monitor stream, region restrictions. Monetization via `liveBroadcasts.update` (insert-only on that resource) — separate step after broadcast creation. Remove unsupported `categoryId` (not a `liveBroadcast` field, silently ignored) ### Design decisions (v3) @@ -587,51 +589,24 @@ the validator → persisted), compositor bar overlay (top/bottom + above-flash), 3. **Health monitoring** — poll `liveStreams.list` `healthStatus` + `configurationIssues[]`, surface banner only on warning/error 4. **Live chat** — poll `liveChat/messages`, render in right panel, support Super Chat + membership badges 5. **Error handling** — the YouTube error codes: `errorStreamInactive`, `invalidTransition`, `redundantTransition`, `liveStreamDeletionNotAllowed`, `liveStreamModificationNotAllowed`, `liveBroadcastBindingNotAllowed` +6. **Visibility picker** — Private/Unlisted/Public from the go-live dialog +7. **Full broadcast form** — Core + Advanced tabs with all API-supported fields --- -## TASK 23 — Monetization: subscription billing + in-app unlock + support (2026-08-14, scoped) +## TASK 23 — Monetization: subscription billing + in-app unlock + support -**Goal:** turn the settled monetization policy (see `ai.md` → Monetization) into a paying product line: -annual subscription billing, an in-app unlock seam, and the in-app bug-report → git-issues support loop. +**Goal:** annual subscription billing, in-app unlock seam, and in-app bug-report → git-issues support loop. -**Decisions already locked (2026-08-14, creator):** one paid line = **annual subscription** (early -access **$49.99/yr** → **$99/yr list at GA**; grandfather-while-subscribed, lapse → list on renewal; -no escalation matrix). Free tier unchanged (branding flash + no Alerts; the flash is the free tier's -billboard — distribution, not compromise). Billing **not locked to itch.io** (hunted fact: itch.io has -no native subscription billing); candidates **Gumroad** (native affiliates = tiebreaker) and **Lemon -Squeezy**. Support = in-app bug-report → git issues; "when I get around to it" cadence + emergency -patches. Unlock outcome: flash removal flips `BrandFlashEnabled` off + **Alerts** on. +**Business details (pricing, billing research, revenue projections) in `MONETIZATION.md` (gitignored).** -### Status: 🔶 Scoped — nothing built; this entry records the plan + decisions +### Status: 🔶 Scoped — nothing built -1. ☐ **Billing provider decision** — Gumroad vs Lemon Squeezy (annual subs + license-key verification - APIs; Gumroad's native affiliate program is the tiebreaker if the TASK 23 affiliate line-item matters) -2. ☐ **Unlock mechanism** — license-key entry + online/offline verification + persisted entitlement - (extends `LayoutStore`, schema bump) + renewal/lapse handling (**lapse → branding flash returns** — - the flash is the enforcement and the billboard) -3. ☐ **In-app bug-report → git issues** — a built-in report control that files an issue (title/body/ - log attachment) into the creator's git repo; the support loop, per the settled support model -4. ☐ **"Unlock Premium" seam** — the About hub's greyed button (TASK 21; `PremiumUrl` is the tabled - billing URL) lights up and routes to the billing page / unlock dialog -5. ☐ **Alerts gating** — the Super Chat pop-ins (TASK 3 item 18 / minimal source set item 7) are the - one paid feature; subscription state gates them -6. **Line-item pointers (their own big tasks — do NOT start here):** - - ☐ **Early-access strategy** — the 90-day funnel (founder window $49.99 → GA $99), waitlist, - testimonials/QA, clips → Shorts. Separate planning effort. - - ☐ **Affiliate & ads (phase 2)** — own-channel gear affiliates from day one (organic), a - ytLlive referral program gated behind GA + happy users, YPP as the channel's ad revenue, paid - ads deferred. - - ☐ **Channel revival (the prequel to both)** — **resume streaming**: pair-programming dev - streams via **OBS** (ytLlive can't go live yet — private-only + `_rtmpUrlProvider` null) + - **The Division 2** gaming streams under the **"gramps/GOAT"** persona (67, gaming since 1981). - 3D-printing content retired (printer hard down, novelty gone). **Dogfood switch** — once TASK 5 + - go-live capability land, stream *with* ytLlive; Division 2 (loud game + voice) is the demo for the - ducking/voice-filter story. The flash billboard + the grandfather promise are the monetization - hooks this line-item feeds. - -**Out of scope (TASK 23):** the early-access strategy itself, the affiliate program, channel content -planning — those are separate line-item tasks. TASK 23 owns billing + unlock + support + gating only. +1. ☐ **Billing provider decision** — Gumroad vs Lemon Squeezy (see MONETIZATION.md for comparison) +2. ☐ **Unlock mechanism** — license-key entry + online/offline verification + persisted entitlement (extends `LayoutStore`, schema bump) + renewal/lapse handling +3. ☐ **In-app bug-report → git issues** — built-in report control; good error codes first, optional log attachment with transparency about what's being sent +4. ☐ **"Unlock Premium" seam** — the About hub's greyed button (`PremiumUrl`) lights up and routes to billing/unlock +5. ☐ **Alerts gating** — Super Chat pop-ins gated behind subscription state --- @@ -791,9 +766,26 @@ No changes to `MusicPlayer`, the 0.20 cap, the ducker, or the meter zones. Build --- +## TASK 26 — Social media launch kit + +**Goal:** marcom/social media assets and strategy for v1 launch. + +**Business details (positioning, messaging, platform strategy, launch assets) in `MARCOM.md` (gitignored).** + +### Status: 🔶 Scoped — nothing built; queued after all v1 features ship + +1. ☐ Product positioning & messaging (one-liner, elevator pitch, competitive positioning) +2. ☐ Social media swipe files (pre-written posts for supporters) +3. ☐ Launch day assets (demo video, screenshots, GIFs, social graphics, press kit) +4. ☐ Platform strategy (YouTube, Reddit, indie dev communities, Product Hunt) +5. ☐ Founder story (67-year-old dev building his own streaming app) +6. ☐ Email announcement templates +7. ☐ "Build in public" livestream angle (stream the coding of ytLlive with ytLlive) + +--- + ## Backlog (future versions) 1. v0.2 — Recording to local file (recordings carry the branding flash — see TASK 3 / `ai.md` Monetization) -2. v0.3 — Stream scheduling -3. v0.4 — Multi-destination restreaming -4. v0.5 — Stream clipping +2. v0.4 — Multi-destination restreaming (if needed; casual streamers may outgrow ytLlive first — "congrats, you're ready for OBS") +3. v0.5 — Stream clipping diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 670dffb..bf652eb 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -28,6 +28,7 @@ public class MainViewModel : ViewModelBase private readonly DispatcherTimer _liveTimer; private readonly DispatcherTimer _volumeFlashTimer; private readonly DispatcherTimer _gameVolumeFlashTimer; + private readonly DispatcherTimer _healthPollTimer; private Scene? _activeScene; private SceneElement? _selectedElement; @@ -63,6 +64,10 @@ public class MainViewModel : ViewModelBase private string _streamDescription = string.Empty; private string _streamVisibility = "Private"; private string? _currentBroadcastId; + private string? _reusableStreamUrl; + private ReusableStream? _reusableStream; + private string? _healthIssueBanner; + private string _healthIssueBackground = "Transparent"; private string _windowTitle = "ytLlive"; private string _topBarBackground = "#16213e"; private string _previewGlowBrush = "Transparent"; @@ -778,6 +783,23 @@ public class MainViewModel : ViewModelBase set => SetProperty(ref _currentHealth, value); } + /// Report-by-exception YouTube health (TASK 5 item 3): non-null only + /// while a warning/error configuration issue exists — the banner's text. + /// good/ok/noData keep it null so nothing renders. + public string? HealthIssueBanner + { + get => _healthIssueBanner; + private set => SetProperty(ref _healthIssueBanner, value); + } + + /// Banner background: app red (#8f1f1f) for an error issue, amber + /// (#b8860b) for a warning. Transparent when no banner is shown. + public string HealthIssueBackground + { + get => _healthIssueBackground; + private set => SetProperty(ref _healthIssueBackground, value); + } + public string StreamTitle { get => _streamTitle; @@ -1097,7 +1119,6 @@ public class MainViewModel : ViewModelBase _liveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; _liveTimer.Tick += OnLiveTimerTick; - _volumeFlashTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) }; _volumeFlashTimer.Tick += (_, _) => EndVolumeFlash(); @@ -1112,6 +1133,9 @@ public class MainViewModel : ViewModelBase _brandFlashOffTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(750) }; _brandFlashOffTimer.Tick += (_, _) => { _brandFlashOffTimer.Stop(); BrandFlashActive = false; }; + _healthPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) }; + _healthPollTimer.Tick += OnHealthPollTick; + Scenes.CollectionChanged += OnScenesChanged; AddSceneCommand = new RelayCommand(name => AddScene(name as string ?? string.Empty)); @@ -1201,7 +1225,7 @@ public class MainViewModel : ViewModelBase _screenCaptureManager.CaptureFailed += (key, message) => AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}"); - _rtmpUrlProvider = () => null; // TASK 5: the reusable stream's ingest URL + _rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL _framePump = new FramePump( sceneProvider: () => ActiveScene, frameResolver: ResolveOutputFrame, @@ -1214,6 +1238,10 @@ public class MainViewModel : ViewModelBase _framePump.HealthUpdated += OnFramePumpHealthUpdated; LoadLayout(); + // The cached reusable stream gives the pump its RTMP URL the moment go-live + // starts (the pump reads the URL once at startup — no waiting on YouTube). + _reusableStream = _layoutStore.LoadReusableStream(); + _reusableStreamUrl = _reusableStream?.RtmpUrl; _ = LoadSavedSessionAsync(); AppLog.Write("MainViewModel ctor end"); } @@ -2129,35 +2157,98 @@ public class MainViewModel : ViewModelBase StreamStatus = StreamStatus.Streaming; ResetHealth(StreamStatus.Streaming); _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); - _ = CreateBroadcastAsync(); - _ = _framePump.StartAsync(); // never throws; failures log + surface via Failed + _ = PrepareAndStartLiveAsync(); } } - /// Creates the YouTube broadcast (always private — enforced in the - /// stream service) and remembers its ID for TASK 5's bind/transition. A - /// failure surfaces as an error health state, never a crash. - private async Task CreateBroadcastAsync() + /// One-click go-live (TASK 5): ensure the reusable stream, create a + /// broadcast bound to it, then start the frame pump. The pump reads the RTMP + /// URL once at startup (FramePump.StartAsync), so the stream URL must be set + /// before the pump starts — hence the ordering here. Failures surface as an + /// error health state, never a crash. + private async Task PrepareAndStartLiveAsync() { try { + var stream = await _youtubeStream.GetOrCreateReusableStreamAsync(); + if (stream == null) + { + AppLog.Write("Reusable stream unavailable; check the OAuth session"); + StreamStatus = StreamStatus.Error; + return; + } + _reusableStream = stream; + _reusableStreamUrl = stream.RtmpUrl; + _layoutStore.SaveReusableStream(stream); + _currentBroadcastId = await _youtubeStream.CreateBroadcast( - StreamTitle, StreamDescription, DateTime.UtcNow); + StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id); if (_currentBroadcastId == null) { AppLog.Write("Broadcast creation failed; check the OAuth session"); StreamStatus = StreamStatus.Error; + return; } - else - { - AppLog.Write($"Broadcast created: {_currentBroadcastId}"); - } + AppLog.Write($"Broadcast created: {_currentBroadcastId}"); } catch (Exception ex) { - AppLog.Write($"Broadcast creation failed: {ex.Message}"); + AppLog.Write($"Go-live preparation failed: {ex.Message}"); StreamStatus = StreamStatus.Error; + return; } + + await _framePump.StartAsync(); // never throws; failures log + surface via Failed + + // Health polling (TASK 5 item 3): poll immediately, then every 30s while + // live. Report-by-exception — a banner appears only on warning/error. + _healthPollTimer.Start(); + _ = PollHealthAsync(); + } + + private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync(); + + /// One liveStreams.list poll (TASK 5 item 3): updates the YouTube + /// health state + the report-by-exception banner. Never throws — a poll + /// failure logs and leaves the last state untouched. + private async Task PollHealthAsync() + { + if (_reusableStream == null || !IsLive) return; + try + { + var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id); + if (health == null) + { + AppLog.Write("Stream health poll failed; check the OAuth session"); + return; + } + CurrentHealth.HealthStatus = health.HealthStatus; + CurrentHealth.HealthMessage = health.HealthStatus; + CurrentHealth.ConfigurationIssues.Clear(); + CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues); + OnPropertyChanged(nameof(CurrentHealth)); + ApplyHealthIssue(health.ConfigurationIssues); + } + catch (Exception ex) + { + AppLog.Write($"Stream health poll failed: {ex.Message}"); + } + } + + // Report-by-exception: good/ok/noData (or info-only issues) clear the banner; + // the first warning/error issue raises it, colored by the worst severity. + private void ApplyHealthIssue(List issues) + { + var report = StreamHealthReporter.BannerFor(issues); + if (report.Text == null) + { + HealthIssueBanner = null; + HealthIssueBackground = "Transparent"; + return; + } + + HealthIssueBackground = report.IsError ? "#8f1f1f" : "#b8860b"; + HealthIssueBanner = report.Text; } private void StopStream() @@ -2329,6 +2420,11 @@ public class MainViewModel : ViewModelBase CurrentHealth.DroppedFrames = 0; CurrentHealth.StreamDuration = TimeSpan.Zero; CurrentHealth.LastError = null; + CurrentHealth.HealthStatus = null; + CurrentHealth.HealthMessage = null; + CurrentHealth.ConfigurationIssues.Clear(); + HealthIssueBanner = null; + HealthIssueBackground = "Transparent"; OnPropertyChanged(nameof(CurrentHealth)); } @@ -2351,6 +2447,7 @@ public class MainViewModel : ViewModelBase else { _liveTimer.Stop(); + _healthPollTimer.Stop(); _brandFlashTimer.Stop(); _brandFlashOffTimer.Stop(); BrandFlashActive = false; diff --git a/ai.md b/ai.md index d85fc94..19f2f00 100644 --- a/ai.md +++ b/ai.md @@ -113,7 +113,7 @@ C# / WPF (.NET 8) following MVVM: - `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). A **graceful End Livestream signs out**: `StopStream()` clears the session + token, so the next go-live needs a fresh sign-in; a crash never runs End, so the token survives and the creator stays signed in. `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`) - Scene/source/asset layout + the social bar persist (SQLite, schema v8); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — subscription entitlement verification pending, TASK 23) -- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream +- `YouTubeStreamService` manages the **variable reusable stream** (shipped 2026-08-16): `GetOrCreateReusableStreamAsync` lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, creating it only on first use (`resolution=variable`, `frameRate=variable`); the stream is cached via `LayoutStore` (`SaveReusableStream`/`LoadReusableStream`, Settings table) and bound at broadcast insert (`contentDetails.boundStreamId`). Health (shipped 2026-08-16): `GetStreamHealthAsync(streamId)` polls `liveStreams?part=status` for `healthStatus` + `configurationIssues[]` → `StreamHealth`; banner decision in pure `StreamHealthReporter` - Webcam capture is shipped (milestone 1); the live desktop/game backdrop is shipped (ship task #1); **the output compositor (TASK 4 ship step 1) is SHIPPED**, **the FFmpeg locator (TASK 4 ship step 2) is SHIPPED**, **the encoder + RTMP push (TASK 4 ship step 3) is SHIPPED**, **WASAPI audio capture (TASK 4 ship step 4) is SHIPPED** — full plan in `TASKS.md`; the frame-pipeline wiring follows (its own PR) - `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead @@ -461,8 +461,10 @@ seam:** `Func`, `Func` resolver, `Func` returning null - until TASK 5 supplies the reusable stream's ingest URL, so go-live runs the current visual flow. + the encoder entirely. `MainViewModel._rtmpUrlProvider` is that seam — a `Func` that now yields + the reusable stream's ingest URL (TASK 5, shipped 2026-08-16): loaded from the `LayoutStore` cache at + startup and set fresh by `PrepareAndStartLiveAsync` before the pump starts. **The pump reads the URL + once at startup**, which is why go-live ensures the stream BEFORE `StartAsync`. - **Stop ordering matters:** `StopAsync` stops the encoder (closes stdin → EOF → ffmpeg finalizes+exits) **before** awaiting the loop, because closing stdin unblocks a write stuck on pipe backpressure — the reverse order would deadlock. `ProcessFailed` self-stops the pump. `Failed` while live flips @@ -472,7 +474,7 @@ seam:** `Func`, `Func` resolver, `Func