a9d5f29084
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.
119 lines
5.1 KiB
C#
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();
|
|
} |