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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user