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:
@@ -5,3 +5,7 @@ Helpers/OAuthCredentials.cs
|
||||
*.suo
|
||||
.vs/
|
||||
*.DotSettings.user
|
||||
|
||||
# Confidential business files (gitignored, not committed)
|
||||
MONETIZATION.md
|
||||
MARCOM.md
|
||||
|
||||
+94
-11
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user