Files
LlamaCasty/Services/ChannelStatsService.cs
T
gramps a9d5f29084 feat(ypp): YPP journey tracker slice 1 — the YPP pull-out below Stream Settings (TASK 39, Good Dog ONE test)
Current-OAuth-scope data, no re-consent: subscriber bars for both tiers (500 fan-funding /
1,000 ad-revenue; the 2027-02-01 doubling is versioned date-aware data in YppThresholds),
3-uploads/90d, live standing from channels.list auditDetails, self-reported 2FA/AdSense
checkboxes + deep links (Google 2-Step / youtube.com/earn), and the "what counts" education.
Every refresh appends a YppSnapshot (SQLite v10) so the analytics slice has history from day one.

- ChannelStatsService (channels.list statistics,auditDetails,contentDetails + playlistItems.list,
  virtual CaptureCurrentAsync = test seam); YppTrackerViewModel mirrors LiveBroadcastFormViewModel.
- MainViewModel.Ypp.cs: Ypp property, ChannelStatsFactoryOverride seam, one-open-at-a-time drawer
  exclusivity (both directions); account sign-in/out/restore hooks in Account.cs.
- PreviewPane.xaml: TextPullOutHost is now a drawer bank [broadcast][ypp][stacked white tabs];
  outside-click collapse covers both drawers. MainWindow.xaml.cs updated.
- Wait - watchers: the whole feature already reviewed against YouTube's own docs (YPP Earn tab +
  API availability) before building — see TASKS/task-39-ypp-journey-tracker.md.
- ONE integration test: YppPullOutTests (snapshot capture/persist + checklist round-trip +
  drawer exclusivity). 313/313 pass, 0 warnings.
- Memory in same commit: TASKS.md row, ai.md Journey-tracker bullet (roadmap→reality), HANDOFF.md.

Slice 2 (Analytics yt-analytics.readonly re-consent → watch-hours/Shorts + velocity/ETA +
sparkline) is queued behind this, not merged.
2026-09-22 08:07:54 -07:00

119 lines
5.1 KiB
C#

using System.Net.Http;
using System.Text.Json;
using ytLive.Models;
using AppLog = ytLive.Helpers.AppLog;
namespace ytLive.Services;
/// <summary>
/// Pulls a connected channel's YPP-relevant numbers from the YouTube Data API
/// (TASK 39, slice 1 — the current OAuth scopes suffice, no re-consent):
/// subscriptions/views/videos + audit standing via channels.list, and the count
/// of public uploads in the trailing 90 days via playlistItems.list on the
/// uploads playlist. Long-form watch hours and Shorts-feed views need the
/// Analytics API (a later slice behind the IChannelStatsProvider seam).
/// </summary>
public class ChannelStatsService
{
private readonly YouTubeAuthService _auth;
private readonly HttpClient _http;
private const string ApiBase = "https://www.googleapis.com/youtube/v3";
public ChannelStatsService(YouTubeAuthService auth, HttpClient? http = null)
{
_auth = auth;
_http = http ?? new HttpClient();
}
private async Task<bool> EnsureToken()
{
if (_auth.CurrentChannel == null) return false;
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
return await _auth.RefreshToken();
return true;
}
/// <summary>Captures the channel's current stats into a snapshot, or null when
/// there's no session, a request fails, or the API returns nothing. Never throws.</summary>
public virtual async Task<YppStatSnapshot?> CaptureCurrentAsync()
{
if (!await EnsureToken()) return null;
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel!.AccessToken);
var response = await _http.GetAsync(
$"{ApiBase}/channels?mine=true&part=statistics,auditDetails,contentDetails");
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"YPP stats: channels.list failed ({(int)response.StatusCode})");
return null;
}
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
if (!json.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
return null;
var channel = items[0];
var statistics = channel.GetProperty("statistics");
var uploadsPlaylistId = channel.TryGetProperty("contentDetails", out var contentDetails)
&& contentDetails.TryGetProperty("relatedPlaylists", out var related)
&& related.TryGetProperty("uploads", out var uploads)
? uploads.GetString()
: null;
var uploadsLast90Days = uploadsPlaylistId != null
? await CountUploadsLast90DaysAsync(uploadsPlaylistId)
: 0;
return new YppStatSnapshot
{
CapturedAtUtc = DateTime.UtcNow,
SubscriberCount = statistics.TryGetProperty("subscriberCount", out var subs) ? subs.GetInt64() : 0,
VideoCount = statistics.TryGetProperty("videoCount", out var videos) ? videos.GetInt64() : 0,
TotalViews = statistics.TryGetProperty("viewCount", out var views) ? views.GetInt64() : 0,
UploadsLast90Days = uploadsLast90Days,
CommunityGuidelinesGoodStanding = ReadStandingFlag(channel, "communityGuidelinesGoodStanding"),
CopyrightStrikesGoodStanding = ReadStandingFlag(channel, "copyrightStrikesGoodStanding"),
ContentIdClaimsGoodStanding = ReadStandingFlag(channel, "contentIdClaimsGoodStanding"),
OverallGoodStanding = ReadStandingFlag(channel, "overallGoodStanding"),
};
}
/// <summary>Counts uploads published within the trailing 90 days, scanning the
/// most recent 50 from the uploads playlist (generous beyond the 3/90-day YPP
/// requirement; the YPP tab labels it as the recent-50 window).</summary>
private async Task<int> CountUploadsLast90DaysAsync(string uploadsPlaylistId)
{
var response = await _http.GetAsync(
$"{ApiBase}/playlistItems?part=snippet&playlistId={Uri.EscapeDataString(uploadsPlaylistId)}&maxResults=50");
if (!response.IsSuccessStatusCode)
{
AppLog.Write($"YPP stats: playlistItems.list failed ({(int)response.StatusCode})");
return 0;
}
var json = JsonSerializer.Deserialize<JsonElement>(await response.Content.ReadAsStringAsync());
if (!json.TryGetProperty("items", out var items)) return 0;
var cutoff = DateTime.UtcNow.AddDays(-90);
var count = 0;
foreach (var item in items.EnumerateArray())
{
if (!item.TryGetProperty("snippet", out var snippet)
|| !snippet.TryGetProperty("publishedAt", out var published)
|| !DateTime.TryParse(published.GetString(), out var publishedAt))
{
continue;
}
if (publishedAt.ToUniversalTime() >= cutoff)
count++;
}
return count;
}
private static bool ReadStandingFlag(JsonElement channel, string property)
=> channel.TryGetProperty("auditDetails", out var audit)
&& audit.TryGetProperty(property, out var flag)
&& flag.GetBoolean();
}