using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using ytLive.Models; using AppLog = ytLive.Helpers.AppLog; namespace ytLive.Services; /// /// Manages YouTube live stream lifecycle — create broadcasts, /// bind stream keys, monitor health. /// public class YouTubeStreamService { private readonly YouTubeAuthService _auth; private readonly HttpClient _http; private const string ApiBase = "https://www.googleapis.com/youtube/v3"; public YouTubeStreamService(YouTubeAuthService auth, HttpClient? http = null) { _auth = auth; _http = http ?? new HttpClient(); } private async Task EnsureToken() { if (_auth.CurrentChannel == null) return false; if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5)) return await _auth.RefreshToken(); return true; } public async Task CreateBroadcast(string title, string description, DateTime scheduledStartTime, string? streamId = null) { if (!await EnsureToken()) return null; var broadcast = new { snippet = new { title, description, scheduledStartTime = scheduledStartTime.ToString("o"), categoryId = "22" // People & Blogs }, status = new { // Private-only by enforcement (ship step 7) — the Go Live dialog // is locked to Private and the service refuses anything else. privacyStatus = "private", selfDeclaredMadeForKids = false }, // 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); var response = await _http.PostAsJsonAsync( $"{ApiBase}/liveBroadcasts?part=snippet,status,contentDetails", broadcast); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(); var data = JsonSerializer.Deserialize(json); return data.GetProperty("id").GetString(); } private static Dictionary BuildContentDetails(string? streamId) { var details = new Dictionary { ["enableAutoStart"] = true, ["enableAutoStop"] = true, ["enableMonitorStream"] = false, ["latencyPreference"] = "low", }; if (streamId != null) details["boundStreamId"] = streamId; return details; } /// /// Pushes the creator-editable broadcast fields to YouTube via /// liveBroadcasts.update (part=snippet,status). Valid any time, including /// while live. Returns null on success, otherwise a human-readable error. /// Note: liveBroadcasts.update REPLACES the snippet part, so scheduledStartTime /// is re-sent unchanged from the stored value — omitting it would clear the /// schedule server-side. /// public async Task UpdateBroadcast(string broadcastId, BroadcastMetadata meta) { if (!await EnsureToken()) return "not signed in"; var tags = meta.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList(); var broadcast = new { id = broadcastId, snippet = new { title = meta.Title, description = meta.Description, tags = tags, // update replaces the whole snippet part; echo the original schedule scheduledStartTime = (meta.ScheduledStartTime ?? DateTime.UtcNow).ToString("o"), categoryId = "22" }, status = new { privacyStatus = string.IsNullOrWhiteSpace(meta.Visibility) ? "private" : meta.Visibility.ToLowerInvariant(), selfDeclaredMadeForKids = meta.MadeForKids } }; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); var response = await _http.PutAsJsonAsync( $"{ApiBase}/liveBroadcasts?part=snippet,status", broadcast); if (response.IsSuccessStatusCode) return null; var body = await response.Content.ReadAsStringAsync(); AppLog.Write($"Broadcast update failed ({(int)response.StatusCode}): {body}"); return $"YouTube rejected the update ({(int)response.StatusCode})"; } /// Returns the channel's reusable stream (TASK 5 design decision 2): /// lists existing streams first and reuses the one with cdn.isReusable=true, /// creating it with variable resolution/frame rate on first use. Binding to a /// broadcast happens at broadcast insert (boundStreamId), so one reusable /// stream serves every broadcast without recreation. public async Task GetOrCreateReusableStreamAsync() { if (!await EnsureToken()) return null; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); var listResponse = await _http.GetAsync( $"{ApiBase}/liveStreams?mine=true&part=snippet,cdn,status"); if (!listResponse.IsSuccessStatusCode) return null; var listJson = JsonSerializer.Deserialize( await listResponse.Content.ReadAsStringAsync()); if (listJson.TryGetProperty("items", out var items)) { foreach (var item in items.EnumerateArray()) { if (item.TryGetProperty("cdn", out var cdn) && cdn.TryGetProperty("isReusable", out var reusable) && reusable.GetBoolean()) { var parsed = ParseStream(item); if (parsed != null) return parsed; } } } var stream = new { snippet = new { title = "LlamaCasty Reusable Stream" }, cdn = new { ingestionType = "rtmp", resolution = "variable", frameRate = "variable", isReusable = true } }; var response = await _http.PostAsJsonAsync( $"{ApiBase}/liveStreams?part=snippet,cdn", stream); if (!response.IsSuccessStatusCode) return null; var json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); return ParseStream(json); } private static ReusableStream? ParseStream(JsonElement item) { if (!item.TryGetProperty("id", out var id) || !item.TryGetProperty("cdn", out var cdn) || !cdn.TryGetProperty("ingestionInfo", out var info)) { return null; } var streamId = id.GetString(); var address = info.TryGetProperty("ingestionAddress", out var addr) ? addr.GetString() : null; var name = info.TryGetProperty("streamName", out var nameEl) ? nameEl.GetString() : null; if (string.IsNullOrWhiteSpace(streamId) || string.IsNullOrWhiteSpace(address) || string.IsNullOrWhiteSpace(name)) return null; return new ReusableStream(streamId, address, name); } /// Polls the reusable stream's health (TASK 5 item 3) via /// liveStreams.status — report-by-exception: good/ok/noData yield an empty /// issue list, warning/error entries in configurationIssues[] drive the /// banner. Null on failure or an empty response, never a throw. public async Task GetStreamHealthAsync(string streamId) { if (!await EnsureToken()) return null; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); var response = await _http.GetAsync( $"{ApiBase}/liveStreams?part=status&id={streamId}"); if (!response.IsSuccessStatusCode) return null; var json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); var items = json.GetProperty("items"); if (items.GetArrayLength() == 0) return null; var status = items[0].GetProperty("status"); var health = new StreamHealth { 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; } /// Returns the liveChatId for a broadcast by fetching its contentDetails. /// The liveChatId is needed to poll chat messages. Called after broadcast creation /// in PrepareAndStartLiveAsync. public async Task GetBroadcastLiveChatIdAsync(string broadcastId) { if (!await EnsureToken()) return null; _http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken); var response = await _http.GetAsync( $"{ApiBase}/liveBroadcasts?part=contentDetails&id={broadcastId}"); if (!response.IsSuccessStatusCode) return null; var json = await response.Content.ReadAsStringAsync(); var data = JsonSerializer.Deserialize(json); var items = data.GetProperty("items"); if (items.GetArrayLength() == 0) return null; var contentDetails = items[0].GetProperty("contentDetails"); if (contentDetails.TryGetProperty("liveChatId", out var liveChatId)) return liveChatId.GetString(); return null; } }