TASK 5 items 1-3: reusable stream, health banner, docs refresh

- Reusable stream: GetOrCreateReusableStreamAsync, cached via LayoutStore, bound at broadcast insert
- Health monitoring: GetStreamHealthAsync polls liveStreams.status, report-by-exception banner
- Go-live order: ensure stream → create broadcast → then start pump (URL must exist first)
- Full broadcast form scoped (TASK 5 items 6-7) in TASKS.md + ai.md
- TASK 23 trimmed to technical scope, TASK 26 added (marcom/launch kit)
- Confidential files: MONETIZATION.md + MARCOM.md (gitignored, not committed)
- .gitignore updated for confidential business files
- HANDOFF.md updated with session state and next steps
This commit is contained in:
2026-08-16 17:48:37 -07:00
parent 92f1471ab4
commit 0fac1e6a5a
16 changed files with 810 additions and 140 deletions
+4
View File
@@ -5,3 +5,7 @@ Helpers/OAuthCredentials.cs
*.suo
.vs/
*.DotSettings.user
# Confidential business files (gitignored, not committed)
MONETIZATION.md
MARCOM.md
+94 -11
View File
@@ -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)
+12 -2
View File
@@ -74,6 +74,7 @@
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
@@ -136,7 +137,16 @@
</Border>
<!-- ═══ MIDDLE: Main Content ═══ -->
<Grid Grid.Row="1" Margin="8">
<!-- YouTube health banner (TASK 5 item 3): report-by-exception — visible
ONLY while a warning/error configuration issue exists. Amber for
warning, darker red for error (HealthIssueBackground). -->
<Border Grid.Row="1" Background="{Binding HealthIssueBackground}"
Visibility="{Binding HealthIssueBanner, Converter={StaticResource NotNullToVis}}">
<TextBlock Text="{Binding HealthIssueBanner}" Foreground="White" FontSize="13"
FontWeight="SemiBold" Margin="16,8" TextTrimming="CharacterEllipsis"/>
</Border>
<Grid Grid.Row="2" Margin="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="*"/>
@@ -869,7 +879,7 @@
</Grid>
<!-- ═══ BOTTOM BAR: Stream Health + Audio ═══ -->
<Border Grid.Row="2" Background="#0f3460" Padding="16,6">
<Border Grid.Row="3" Background="#0f3460" Padding="16,6">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
+19 -2
View File
@@ -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<StreamConfigurationIssue> ConfigurationIssues { get; } = new();
}
public enum StreamIssueSeverity
{
Info,
Warning,
Error
}
public class StreamConfigurationIssue
{
public StreamIssueSeverity Severity { get; set; }
public string? Type { get; set; }
}
+8
View File
@@ -10,6 +10,14 @@ public class YouTubeChannel
public DateTime TokenExpiry { get; set; }
}
/// <summary>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.</summary>
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();
+29
View File
@@ -615,6 +615,35 @@ public class LayoutStore : IDisposable
insert.ExecuteNonQuery();
}
/// <summary>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.</summary>
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<Scene> scenes, Webcam? webcam, SocialsConfig? socials, Music? music = null)
{
using var tx = _connection.BeginTransaction();
+26
View File
@@ -0,0 +1,26 @@
using ytLive.Models;
namespace ytLive.Services;
/// <summary>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.</summary>
public static class StreamHealthReporter
{
public static HealthIssueReport BannerFor(IEnumerable<StreamConfigurationIssue> 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);
}
}
/// <summary>null Text = no banner. IsError colors it: error → app red, else amber.</summary>
public record HealthIssueReport(string? Text, bool IsError);
+103 -42
View File
@@ -29,7 +29,7 @@ public class YouTubeStreamService
return true;
}
public async Task<string?> CreateBroadcast(string title, string description, DateTime scheduledStartTime)
public async Task<string?> 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<string?> BindStream(string broadcastId, string streamKey)
private static Dictionary<string, object?> BuildContentDetails(string? streamId)
{
var details = new Dictionary<string, object?>
{
["enableAutoStart"] = true,
["enableAutoStop"] = true,
["enableMonitorStream"] = false,
["latencyPreference"] = "low",
};
if (streamId != null) details["boundStreamId"] = streamId;
return details;
}
/// <summary>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.</summary>
public async Task<ReusableStream?> 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<JsonElement>(
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<JsonElement>(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<JsonElement>(await response.Content.ReadAsStringAsync());
return ParseStream(json);
}
public async Task<StreamHealth?> 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);
}
/// <summary>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.</summary>
public async Task<StreamHealth?> 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<JsonElement>(json);
var items = data.GetProperty("items");
var json = JsonSerializer.Deserialize<JsonElement>(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;
}
}
+3 -2
View File
@@ -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) |
+37 -45
View File
@@ -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
+111 -14
View File
@@ -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);
}
/// <summary>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.</summary>
public string? HealthIssueBanner
{
get => _healthIssueBanner;
private set => SetProperty(ref _healthIssueBanner, value);
}
/// <summary>Banner background: app red (#8f1f1f) for an error issue, amber
/// (#b8860b) for a warning. Transparent when no banner is shown.</summary>
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();
}
}
/// <summary>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.</summary>
private async Task CreateBroadcastAsync()
/// <summary>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.</summary>
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();
/// <summary>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.</summary>
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<StreamConfigurationIssue> 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;
+39 -21
View File
@@ -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<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
- **`StartAsync` never throws** — the VM fires-and-forgets it from the sync command handler; failures log
+ surface via the `Failed` event. `EncoderOptions == null` means "no RTMP URL": the pump logs and skips
the encoder entirely. `MainViewModel._rtmpUrlProvider` is that seam — a `Func<string?>` 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<string?>` 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<Scene?>`, `Func<SceneElement, VideoFrame?>` resolver, `Func<Compos
a background thread) and copies into `CurrentHealth` (the bottom bar's existing `CurrentHealth.*`
bindings). `ResetHealth(status)` zeroes dropped/duration on go-live and on End so stats never linger
from a previous session; bitrate/FPS stay on the tier's targets (`ApplyStreamQuality`). The bar shows
real encoder values once TASK 5 fills `_rtmpUrlProvider`; until then the pump skips the encoder.
real encoder values since TASK 5 (2026-08-16) wired the reusable stream URL into `_rtmpUrlProvider`.
- **`MainViewModel` owns the resolver** (`ResolveOutputFrame`): `WebcamSceneConfig` →
`CameraManager.GetLatestFrame(WebcamId)`, `Source { IsLiveCapture, CaptureKey }` →
`ScreenCaptureManager.GetLatestFrame(CaptureKey)` (the new accessor mirroring `CameraManager`), image/
@@ -646,25 +648,41 @@ These are the hard facts behind every decision. Full list in `TASKS.md`.
`enableAutoStart=true`, `enableAutoStop=true`, `enableMonitorStream=false`,
`selfDeclaredMadeForKids=false`, `latencyPreference=low`. The encoder starting brings YouTube live.
`enableMonitorStream=false` is what lets us skip the testing stage.
- **Private-only go live (ship step 7, shipped 2026-08-14)** — until v1, go-live is locked to private
streams so a software error can never publish something public. Double-enforced: the Go Live dialog
is locked to Private (`GoLiveViewModel.Visibility` is a get-only `"Private"`, no dropdown — the
settings "Default Visibility" dropdown + `MainViewModel.Visibilities`/`DefaultStreamVisibility` were
removed as dead-ends) AND `YouTubeStreamService.CreateBroadcast` always sends `privacyStatus="private"`
(injectable `HttpClient? http = null` seam; the broadcast-insert integration test asserts the body
carries `"privacyStatus":"private"`). `BeginGoLive` fires `CreateBroadcastAsync` and remembers
`_currentBroadcastId` for TASK 5's bind/transition (failure → `StreamStatus.Error`, never a crash);
`StopStream` clears it. The REC sign shows a dark-red **PRIVATE badge** (next to REC, `IsLivePrivate`)
whenever the live stream is private.
- **Variable reusable stream** — `liveStreams.insert` once per channel with
`cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`; cache the ingestion URL +
stream name and reuse for every broadcast. Any quality tier works without recreating the stream,
and auto step-down is done by us dropping bitrate on the fly (zero API calls).
- **Visibility picker (TASK 5 item 6)** — the "always Private" enforcement was temporary for testing
during development ("always Private until v1"). For v1, the go-live dialog exposes Private/Unlisted/Public
and `YouTubeStreamService.CreateBroadcast` uses the dialog's selection (not a hardcoded override).
The PRIVATE badge in the top bar still shows when the stream is actually private.
- **Full broadcast form (TASK 5 item 7)** — the go-live dialog exposes all YouTube API-supported fields.
**Core tab** (always visible): title, description, visibility (Private/Unlisted/Public), made-for-kids,
schedule (start + optional end datetime). **Advanced tab** (expandable, sane defaults): latency
(Normal/Low/Ultra-Low, default Low), DVR (default on), embed (default on), record-from-start
(default on), projection (rectangular/360°, default rectangular), closed captions
(disabled/embedded/HTTP, default disabled), auto-start (default on), auto-stop (default on),
monitor stream (default off, for testing), region restrictions (country codes, optional).
`categoryId` is removed from `CreateBroadcast` (not a `liveBroadcast` field, silently ignored).
Monetization via `liveBroadcasts.update` (not settable on insert) — separate step after broadcast
creation. Go-live order (TASK 5, shipped 2026-08-16):
`BeginGoLive` → `PrepareAndStartLiveAsync` — ensure the reusable stream (`GetOrCreateReusableStreamAsync`,
cache it), create the broadcast bound to it (`CreateBroadcast(..., stream.Id)` → `boundStreamId`),
THEN start the pump (the URL must exist before `FramePump.StartAsync`, which reads it once). Failure →
`StreamStatus.Error`, never a crash; `StopStream` clears `_currentBroadcastId`.
- **Variable reusable stream** (shipped 2026-08-16) — `GetOrCreateReusableStreamAsync` lists
`liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once per channel
(`cdn.resolution=variable`, `cdn.frameRate=variable`, `isReusable=true`) only on first use; the
ingestion URL is cached via `LayoutStore` Settings (`SaveReusableStream`/`LoadReusableStream`) and
bound to each broadcast at insert (`boundStreamId`). Any quality tier works without recreating the
stream, and auto step-down is done by us dropping bitrate on the fly (zero API calls).
- **Quality is greyed out while live** — resolution/frameRate/ingestionType are immutable after
stream creation; editing title/description/privacy is fine at any time.
- **Report-by-exception health** — poll `liveStreams.list`; render nothing on `good`/`ok`, surface a
banner only on `configurationIssues[]` with `warning`/`error` severity. Bottom strip = YouTube logo
+ green/red connection dot (clickable → opens the dialog).
- **Report-by-exception health (SHIPPED 2026-08-16, TASK 5 item 3)** — `YouTubeStreamService.GetStreamHealthAsync(streamId)`
polls `liveStreams.list?part=status`; render nothing on `good`/`ok`/`noData`, surface a banner only
on `configurationIssues[]` with `warning`/`error` severity. The decision is the pure
`Services/StreamHealthReporter.BannerFor` (null text = no banner; error beats warning). The VM polls
every **30s while live** (`DispatcherTimer` `_healthPollTimer`, first poll right after go-live) and
clears on End via `ResetHealth`; poll failures log only. UI = a full-width banner strip under the
top bar, `HealthIssueBanner` text + `HealthIssueBackground` (amber `#b8860b` warning / dark red
`#8f1f1f` error), hidden by `NotNullToVis`. The design's bottom-strip YouTube logo + green/red dot
(clickable → dialog) is still queued.
- **One dialog, three states** — not connected / connected-offline (all editable) / live
(title + description + visibility editable; quality + account greyed out). Both entry points
(Start Stream button + bottom strip) open it; prefilled from saved session profile.
+61
View File
@@ -1,3 +1,5 @@
using System.Net;
using System.Text;
using Xunit;
using ytLive.Models;
using ytLive.Services;
@@ -99,6 +101,65 @@ public class FramePumpTests
$"pixel ({x},{y}): expected rgb({r},{g},{b}), got rgb({br},{bg},{bb})");
}
private sealed class FakeHttpHandler : HttpMessageHandler
{
public FakeHttpHandler(string body) => Body = body;
public string Body;
public string? LastUri;
public int RequestCount;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
RequestCount++;
LastUri = request.RequestUri?.ToString();
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(Body, Encoding.UTF8, "application/json"),
});
}
}
/// <summary>The ONE integration test for TASK 5: the real service (hermetic
/// HTTP) returns the reusable stream's RTMP URL, the provider seam hands it
/// to the pump, and the pump starts the encoder with that exact URL — the
/// chain that makes go-live actually push. The stream is reused (listed),
/// never recreated (no POST).</summary>
[Fact]
public async Task ReusableStream_Url_From_Service_Feeds_Encoder_Startup()
{
var handler = new FakeHttpHandler(
"""
{"items":[{"id":"S456","cdn":{"isReusable":true,"ingestionInfo":{"ingestionAddress":"rtmp://a.rtmp.youtube.com/live2","streamName":"KEY123"}}}]}
""");
var auth = new YouTubeAuthService("test-id", "test-secret");
auth.SetSession(new YouTubeChannel
{
AccessToken = "acc-123",
TokenExpiry = DateTime.UtcNow.AddHours(1),
});
var service = new YouTubeStreamService(auth, new HttpClient(handler));
var url = (await service.GetOrCreateReusableStreamAsync())?.RtmpUrl;
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, options: () => url == null
? null
: new EncoderOptions { RtmpUrl = url, Width = 64, Height = 48, Fps = 60 });
await pump.StartAsync();
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", url);
Assert.Equal(1, encoder.StartCount);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", encoder.LastOptions?.RtmpUrl);
Assert.Equal(1, handler.RequestCount); // reuse path: list only, no insert
Assert.NotNull(handler.LastUri);
Assert.Contains("liveStreams", handler.LastUri);
Assert.Contains("mine=true", handler.LastUri);
await pump.StopAsync();
}
[Fact]
public async Task Start_CompositesScene_FeedsEncoder_StopsCleanly()
{
@@ -14,6 +14,35 @@ namespace ytLive.Tests;
/// </summary>
public class LayoutStorePersistenceTests
{
// The reusable stream cache (TASK 5): the RTMP URL must survive restarts so
// the frame pump has it the moment go-live starts, without a YouTube round-trip.
[Fact]
public void ReusableStream_Cache_RoundTrips()
{
var path = Path.Combine(Path.GetTempPath(), $"ytLlive-layout-{Guid.NewGuid():N}.db");
try
{
using (var store = new LayoutStore(path))
{
store.SaveReusableStream(new ReusableStream("S456", "rtmp://a.rtmp.youtube.com/live2", "KEY123"));
var loaded = store.LoadReusableStream();
Assert.NotNull(loaded);
Assert.Equal("S456", loaded.Id);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2", loaded.IngestionAddress);
Assert.Equal("KEY123", loaded.StreamName);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", loaded.RtmpUrl);
}
using (var reopened = new LayoutStore(path))
Assert.Equal("KEY123", reopened.LoadReusableStream()?.StreamName);
}
finally
{
SqliteConnection.ClearAllPools();
try { File.Delete(path); } catch { /* best-effort cleanup */ }
}
}
[Fact]
public void Deleted_Webcam_Config_Does_Not_Return_After_Save_And_Reload()
{
+60
View File
@@ -0,0 +1,60 @@
using Xunit;
using ytLive.Models;
using ytLive.Services;
namespace ytLive.Tests;
/// <summary>
/// Report-by-exception (TASK 5 item 3): the banner decision is pure so the
/// severity/threshold rules are pinned without any HTTP or UI.
/// </summary>
public class StreamHealthReporterTests
{
[Fact]
public void Banner_Is_Null_For_Empty_Or_Info_Only_Issues()
{
Assert.Null(StreamHealthReporter.BannerFor([]).Text);
var report = StreamHealthReporter.BannerFor(new[]
{
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Info, Type = "lowLatency" },
});
Assert.Null(report.Text);
}
[Fact]
public void Warning_Yields_Text_Without_Error_Color()
{
var report = StreamHealthReporter.BannerFor(new[]
{
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Warning, Type = "reconnect" },
});
Assert.Equal("reconnect", report.Text);
Assert.False(report.IsError);
}
[Fact]
public void Error_Issue_Yields_Text_And_Error_Color_Even_With_Warnings()
{
var report = StreamHealthReporter.BannerFor(new[]
{
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Warning, Type = "reconnect" },
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Error, Type = "bitrateHigh" },
});
Assert.Equal("reconnect, bitrateHigh", report.Text);
Assert.True(report.IsError);
}
[Fact]
public void Blank_Issue_Types_Are_Dropped_From_The_Banner()
{
var report = StreamHealthReporter.BannerFor(new[]
{
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Error, Type = null },
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Error, Type = " " },
new StreamConfigurationIssue { Severity = StreamIssueSeverity.Error, Type = "bitrateLow" },
});
Assert.Equal("bitrateLow", report.Text);
Assert.True(report.IsError);
}
}
+175 -1
View File
@@ -17,20 +17,35 @@ public class YouTubeStreamServiceTests
{
public string? LastBody;
public string ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}""";
public Func<HttpRequestMessage, string?>? ResponseSelector;
public readonly List<string> Requests = new();
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add($"{request.Method.Method} {request.RequestUri}");
LastBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult();
var body = ResponseSelector?.Invoke(request) ?? ResponseBody;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"),
Content = new StringContent(body, Encoding.UTF8, "application/json"),
});
}
}
private static YouTubeAuthService CreateAuth() => new("test-id", "test-secret");
private static YouTubeAuthService CreateAuthed()
{
var auth = CreateAuth();
auth.SetSession(new YouTubeChannel
{
AccessToken = "acc-123",
TokenExpiry = DateTime.UtcNow.AddHours(1),
});
return auth;
}
[Fact]
public async Task CreateBroadcast_Always_Sends_Private_PrivacyStatus()
{
@@ -62,4 +77,163 @@ public class YouTubeStreamServiceTests
Assert.Null(id);
Assert.Null(handler.LastBody); // never reached the API
}
// TASK 5: a reusable stream, when given, binds at broadcast insert via
// contentDetails.boundStreamId — no separate bind round-trip. One-click
// go-live also completes the v3 flags (no monitor stream, low latency).
[Fact]
public async Task CreateBroadcast_With_StreamId_Binds_Reusable_Stream_At_Insert()
{
var handler = new RecordingHandler { ResponseBody = """{"id":"BC123","snippet":{"title":"t"}}""" };
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var id = await service.CreateBroadcast("Test Stream", "A description", DateTime.UtcNow, "S456");
Assert.Equal("BC123", id);
Assert.NotNull(handler.LastBody);
Assert.Contains("\"boundStreamId\":\"S456\"", handler.LastBody);
Assert.Contains("\"enableMonitorStream\":false", handler.LastBody);
Assert.Contains("\"latencyPreference\":\"low\"", handler.LastBody);
Assert.Contains("\"privacyStatus\":\"private\"", handler.LastBody);
}
// TASK 5 design decision 2: reuse the existing cdn.isReusable stream instead
// of creating a new one per broadcast.
[Fact]
public async Task GetOrCreateReusableStreamAsync_Reuses_Existing_Reusable_Stream()
{
var handler = new RecordingHandler
{
ResponseBody = """
{"items":[{"id":"S456","cdn":{"isReusable":true,"ingestionInfo":{"ingestionAddress":"rtmp://a.rtmp.youtube.com/live2","streamName":"KEY123"}}}]}
"""
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var stream = await service.GetOrCreateReusableStreamAsync();
Assert.NotNull(stream);
Assert.Equal("S456", stream.Id);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2", stream.IngestionAddress);
Assert.Equal("KEY123", stream.StreamName);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY123", stream.RtmpUrl);
Assert.Contains(handler.Requests, r => r.StartsWith("GET") && r.Contains("liveStreams") && r.Contains("mine=true"));
Assert.DoesNotContain(handler.Requests, r => r.StartsWith("POST"));
}
// TASK 5: first-ever use creates the one reusable stream — variable
// resolution/frame rate so any quality tier works without recreation.
[Fact]
public async Task GetOrCreateReusableStreamAsync_Creates_When_None_Exists()
{
var handler = new RecordingHandler
{
ResponseSelector = request => request.Method.Method == "GET"
? """{"items":[]}"""
: """{"id":"S789","cdn":{"ingestionInfo":{"ingestionAddress":"rtmp://a.rtmp.youtube.com/live2","streamName":"KEY789"}}}"""
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var stream = await service.GetOrCreateReusableStreamAsync();
Assert.NotNull(stream);
Assert.Equal("S789", stream.Id);
Assert.Equal("rtmp://a.rtmp.youtube.com/live2/KEY789", stream.RtmpUrl);
var post = Assert.Single(handler.Requests, r => r.StartsWith("POST"));
Assert.Contains("liveStreams", post);
Assert.Contains("\"isReusable\":true", handler.LastBody);
Assert.Contains("\"resolution\":\"variable\"", handler.LastBody);
Assert.Contains("\"frameRate\":\"variable\"", handler.LastBody);
}
[Fact]
public async Task GetOrCreateReusableStreamAsync_Without_Session_Returns_Null()
{
var handler = new RecordingHandler();
var service = new YouTubeStreamService(CreateAuth(), new HttpClient(handler));
var stream = await service.GetOrCreateReusableStreamAsync();
Assert.Null(stream);
Assert.Empty(handler.Requests); // never reached the API
}
// TASK 5 item 3: liveStreams.status — healthStatus + configurationIssues[]
// parsed into the report-by-exception surface.
[Fact]
public async Task GetStreamHealthAsync_Parses_Status_And_Issue_Severities()
{
var handler = new RecordingHandler
{
ResponseBody = """
{"items":[{"status":{"healthStatus":"bad","configurationIssues":[
{"type":"bitrateHigh","severity":"error"},
{"type":"reconnect","severity":"warning"}]}}]}
"""
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var health = await service.GetStreamHealthAsync("S456");
Assert.NotNull(health);
Assert.Equal("bad", health.HealthStatus);
Assert.Equal(2, health.ConfigurationIssues.Count);
Assert.Equal(StreamIssueSeverity.Error, health.ConfigurationIssues[0].Severity);
Assert.Equal("bitrateHigh", health.ConfigurationIssues[0].Type);
Assert.Equal(StreamIssueSeverity.Warning, health.ConfigurationIssues[1].Severity);
Assert.Contains(handler.Requests, r => r.Contains("liveStreams") && r.Contains("part=status"));
}
[Fact]
public async Task GetStreamHealthAsync_Good_Health_Yields_No_Issues()
{
var handler = new RecordingHandler { ResponseBody = """{"items":[{"status":{"healthStatus":"good"}}]}""" };
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var health = await service.GetStreamHealthAsync("S456");
Assert.Equal("good", health?.HealthStatus);
Assert.Empty(health?.ConfigurationIssues ?? []);
}
[Fact]
public async Task GetStreamHealthAsync_Without_Session_Returns_Null()
{
var handler = new RecordingHandler();
var service = new YouTubeStreamService(CreateAuth(), new HttpClient(handler));
var health = await service.GetStreamHealthAsync("S456");
Assert.Null(health);
Assert.Empty(handler.Requests); // never reached the API
}
/// <summary>The ONE integration test for TASK 5 item 3 (report-by-exception):
/// a healthy stream (good, no issues) produces no banner; a mid-stream
/// problem (bad + error configuration issue) drives the banner text and its
/// error color — through the real service JSON parse into the real reporter.</summary>
[Fact]
public async Task GetStreamHealthAsync_Report_By_Exception_Banner_Only_On_Warning_Or_Error()
{
var handler = new RecordingHandler
{
ResponseBody = """{"items":[{"status":{"healthStatus":"good"}}]}"""
};
var service = new YouTubeStreamService(CreateAuthed(), new HttpClient(handler));
var healthy = await service.GetStreamHealthAsync("S456");
Assert.Equal("good", healthy?.HealthStatus);
Assert.Empty(healthy?.ConfigurationIssues ?? []);
Assert.Null(StreamHealthReporter.BannerFor(healthy!.ConfigurationIssues).Text);
handler.ResponseBody = """
{"items":[{"status":{"healthStatus":"bad","configurationIssues":[
{"type":"bitrateHigh","severity":"error"}]}}]}
""";
var degraded = await service.GetStreamHealthAsync("S456");
Assert.Equal("bad", degraded?.HealthStatus);
var report = StreamHealthReporter.BannerFor(degraded!.ConfigurationIssues);
Assert.Equal("bitrateHigh", report.Text);
Assert.True(report.IsError);
}
}