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
+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) |