diff --git a/Controls/PreviewPane.xaml b/Controls/PreviewPane.xaml index 8cd8816..b8d1b95 100644 --- a/Controls/PreviewPane.xaml +++ b/Controls/PreviewPane.xaml @@ -768,56 +768,66 @@ - - - + + + + + + + + Width="{Binding Ypp.SubscribersBarWidth}"/> - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + - + + + + + + + + + + + + + + + + + diff --git a/Models/YppProgress.cs b/Models/YppProgress.cs new file mode 100644 index 0000000..bd1dbff --- /dev/null +++ b/Models/YppProgress.cs @@ -0,0 +1,56 @@ +using System; +using System.Globalization; + +namespace ytLive.Models; + +/// +/// TASK 39·2: the three metrics that count towards YouTube Partner Program eligibility, as progress +/// towards the ad-revenue thresholds. +/// YouTube decides. This only reports what the published numbers say; it never claims +/// eligibility, and it cannot see strikes, AdSense state, 2FA or the review decision. +/// The view criteria are either/or — 4,000 public watch hours or 10M Shorts views — +/// so "2 of 3" is reported with that qualifier rather than implying all three must pass. +/// +public sealed record YppProgress( + long Subscribers, + long SubscriberTarget, + long WatchHours, + long WatchHoursTarget, + long ShortsViews, + long ShortsViewsTarget) +{ + public bool SubscribersMet => Subscribers >= SubscriberTarget; + public bool WatchHoursMet => WatchHours >= WatchHoursTarget; + public bool ShortsViewsMet => ShortsViews >= ShortsViewsTarget; + + /// Criteria currently met (0–3). Two of three can mean eligible: subs plus EITHER view route. + public int CriteriaMet => (SubscribersMet ? 1 : 0) + (WatchHoursMet ? 1 : 0) + (ShortsViewsMet ? 1 : 0); + + /// True when the subscriber bar AND at least one view route are satisfied — the shape of the + /// published rule. Still not a qualification claim: YouTube reviews it. + public bool MeetsPublishedRule => SubscribersMet && (WatchHoursMet || ShortsViewsMet); + + public double SubscribersFraction => Fraction(Subscribers, SubscriberTarget); + public double WatchHoursFraction => Fraction(WatchHours, WatchHoursTarget); + public double ShortsViewsFraction => Fraction(ShortsViews, ShortsViewsTarget); + + public string SubscribersText => $"{Compact(Subscribers)} / {Compact(SubscriberTarget)}"; + public string WatchHoursText => $"{Compact(WatchHours)} / {Compact(WatchHoursTarget)} h"; + public string ShortsViewsText => $"{Compact(ShortsViews)} / {Compact(ShortsViewsTarget)}"; + + public string CriteriaMetText => CriteriaMet == 1 + ? "1 of 3 criteria met — YouTube reviews eligibility." + : $"{CriteriaMet} of 3 criteria met — YouTube reviews eligibility."; + + private static double Fraction(long value, long target) + => target <= 0 ? 0 : Math.Clamp((double)value / target, 0, 1); + + /// 1,234 → "1,234"; 12,345 → "12.3k"; 1,234,567 → "1.23M". Mono-face friendly and stable + /// in width, so the three bars do not jitter as values move. + internal static string Compact(long value) => value switch + { + >= 1_000_000 => (value / 1_000_000d).ToString("0.##", CultureInfo.InvariantCulture) + "M", + >= 10_000 => (value / 1_000d).ToString("0.#", CultureInfo.InvariantCulture) + "k", + _ => value.ToString("N0", CultureInfo.InvariantCulture), + }; +} diff --git a/Models/YppThresholds.cs b/Models/YppThresholds.cs index 2bd58a9..7fc4b3f 100644 --- a/Models/YppThresholds.cs +++ b/Models/YppThresholds.cs @@ -13,6 +13,10 @@ public static class YppThresholds /// The date the Tier-2 ad-revenue bars double for new applicants. public static readonly DateTime Tier2DoublingDate = new(2027, 2, 1); + /// When these thresholds were last read from YouTube's published requirements. Shown in the + /// drawer and asserted by a test, because YouTube moves the bar and a stale bar must not look current. + public static readonly DateTime AsOfUtc = new(2026, 11, 5); + /// Fan-funding tier (before the full ad/YouTube Premium revenue tier). public static TierThreshold Tier1 { get; } = new("Fan funding", 500, 3_000, 3_000_000); diff --git a/Services/YouTubeAnalyticsService.cs b/Services/YouTubeAnalyticsService.cs new file mode 100644 index 0000000..3d9455a --- /dev/null +++ b/Services/YouTubeAnalyticsService.cs @@ -0,0 +1,103 @@ +using System; +using System.Globalization; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using ytLive.Helpers; + +namespace ytLive.Services; + +/// +/// TASK 39·2: the two numbers YouTube only publishes through the **Analytics** API — long-form watch +/// hours (trailing 12 months) and Shorts-feed views (trailing 90 days). +/// This is the one place the app needs a scope beyond auth/youtube: the Analytics API +/// requires yt-analytics.readonly, which means a one-time Google re-consent. Until that scope is +/// granted, the drawer says so instead of inventing numbers. +/// Values are estimates: Analytics lags, and YouTube's own validity filtering decides what counts +/// towards eligibility. +/// +public interface IWatchMetricsSource +{ + /// Estimated public long-form watch hours over the trailing 12 months, or null when the + /// Analytics scope is not granted / the read failed. + Task GetLongFormWatchHoursAsync(CancellationToken cancellationToken = default); + + /// Shorts-feed views over the trailing 90 days, or null. + Task GetShortsViewsAsync(CancellationToken cancellationToken = default); +} + +public sealed class YouTubeAnalyticsService : IWatchMetricsSource +{ + private const string ApiBase = "https://youtubeanalytics.googleapis.com/v2/reports"; + + private readonly YouTubeAuthService _auth; + private readonly HttpClient _http; + private readonly Func _now; + + public YouTubeAnalyticsService(YouTubeAuthService auth, HttpClient? http = null, Func? now = null) + { + _auth = auth; + _http = http ?? new HttpClient(); + _now = now ?? (() => DateTime.UtcNow); + } + + public async Task GetLongFormWatchHoursAsync(CancellationToken cancellationToken = default) + { + // Minutes watched for long-form videos over the trailing year → hours. + var minutes = await QueryTotalAsync( + "estimatedMinutesWatched", "creatorContentType==video", 365, cancellationToken).ConfigureAwait(false); + return minutes is null ? null : (long)Math.Round(minutes.Value / 60.0); + } + + public async Task GetShortsViewsAsync(CancellationToken cancellationToken = default) + => await QueryTotalAsync("views", "creatorContentType==shorts", 90, cancellationToken) + .ConfigureAwait(false); + + private async Task QueryTotalAsync( + string metric, string filter, int days, CancellationToken cancellationToken) + { + if (!_auth.HasAnalyticsScope) return null; + if (_auth.CurrentChannel?.AccessToken is not { } token) return null; + + var end = _now().Date; + var start = end.AddDays(-days); + var url = $"{ApiBase}?ids=channel==MINE" + + $"&startDate={start.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}" + + $"&endDate={end.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}" + + $"&metrics={Uri.EscapeDataString(metric)}" + + $"&filters={Uri.EscapeDataString(filter)}"; + + try + { + _http.DefaultRequestHeaders.Authorization = new("Bearer", token); + var response = await _http.GetAsync(url, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + AppLog.Write($"Analytics query failed ({metric}): {(int)response.StatusCode}"); + return null; + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return ParseTotal(json); + } + catch (Exception ex) + { + AppLog.Write($"Analytics query failed ({metric}): {ex.Message}"); + return null; + } + } + + /// Pure: the Analytics API answers with columnHeaders + rows; a channel with no data in the + /// window returns no rows at all, which must read as "no data" rather than zero. + internal static long? ParseTotal(string json) + { + using var document = JsonDocument.Parse(json); + if (!document.RootElement.TryGetProperty("rows", out var rows)) return null; + if (rows.ValueKind != JsonValueKind.Array || rows.GetArrayLength() == 0) return null; + var row = rows[0]; + if (row.ValueKind != JsonValueKind.Array || row.GetArrayLength() == 0) return null; + var value = row[0]; + return value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var total) ? total : null; + } +} diff --git a/Services/YppTrackerViewModel.cs b/Services/YppTrackerViewModel.cs index efb7095..ec5e6c2 100644 --- a/Services/YppTrackerViewModel.cs +++ b/Services/YppTrackerViewModel.cs @@ -34,6 +34,10 @@ public sealed class YppTrackerViewModel : ViewModelBase private readonly Func _channelId; private readonly Func _sessionStartedAtUtc; + // TASK 39·2 — the two numbers only the Analytics API publishes. Null source (or a missing scope) + // means the drawer says so rather than inventing values. + private readonly Func _watchMetrics; + private bool _isDrawerOpen; private bool _isLoading; private bool _isRefreshing; @@ -55,7 +59,8 @@ public sealed class YppTrackerViewModel : ViewModelBase Func displayName, Func? rewardEvents = null, Func? channelId = null, - Func? sessionStartedAtUtc = null) + Func? sessionStartedAtUtc = null, + Func? watchMetrics = null) { _store = store; _stats = stats; @@ -64,6 +69,7 @@ public sealed class YppTrackerViewModel : ViewModelBase _rewardEvents = rewardEvents ?? (() => null!); _channelId = channelId ?? (() => null); _sessionStartedAtUtc = sessionStartedAtUtc ?? (() => null); + _watchMetrics = watchMetrics ?? (() => null); RefreshCommand = new RelayCommand(_ => _ = RefreshAsync()); ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen); @@ -212,6 +218,38 @@ public sealed class YppTrackerViewModel : ViewModelBase public double Tier2SubsProgress => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers); public double UploadsProgress => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days); + private long? _watchHours; + private long? _shortsViews; + + /// The three eligibility metrics, with the threshold that applies today (the long-form bar + /// doubles on 1 Feb 2027 — the date is data, not a code change). + public YppProgress Eligibility => new( + SubscriberCount, + YppThresholds.Tier2Now.Subscribers, + _watchHours ?? 0, + YppThresholds.Tier2Now.WatchHours, + _shortsViews ?? 0, + YppThresholds.Tier2Now.ShortsViews); + + /// True once the Analytics scope is granted and both numbers came back. Without it the two + /// view bars cannot be drawn honestly, so the drawer says what is missing instead. + public bool HasWatchMetrics => _watchHours.HasValue && _shortsViews.HasValue; + + public string WatchMetricsMissingText => + "Watch hours and Shorts views need YouTube Analytics access — one-time consent, granted from the " + + "button below. Until then those two bars stay empty."; + + public double SubscribersBarWidth => Eligibility.SubscribersFraction * BarMaxWidth; + public double WatchHoursBarWidth => Eligibility.WatchHoursFraction * BarMaxWidth; + public double ShortsViewsBarWidth => Eligibility.ShortsViewsFraction * BarMaxWidth; + + public string SubscribersText => Eligibility.SubscribersText; + public string WatchHoursText => Eligibility.WatchHoursText; + public string ShortsViewsText => Eligibility.ShortsViewsText; + public string CriteriaMetText => Eligibility.CriteriaMetText; + public string ThresholdsAsOfText => + $"Thresholds as published {YppThresholds.AsOfUtc:d MMMM yyyy} · estimates, can lag."; + public double Tier1SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier1.Subscribers) * BarMaxWidth; public double Tier2SubsBarWidth => Progress(SubscriberCount, YppThresholds.Tier2Now.Subscribers) * BarMaxWidth; public double UploadsBarWidth => Progress(UploadsLast90Days, YppThresholds.UploadsRequiredPer90Days) * BarMaxWidth; @@ -276,6 +314,47 @@ public sealed class YppTrackerViewModel : ViewModelBase await RefreshAsync(); } + /// Asks Analytics for the two view metrics. Missing scope → nulls, and the drawer explains + /// what to do; nothing is guessed. + private async Task RefreshWatchMetricsAsync() + { + var source = _watchMetrics(); + if (source == null) + { + _watchHours = null; + _shortsViews = null; + RaiseProgress(); + return; + } + + try + { + _watchHours = await source.GetLongFormWatchHoursAsync(); + _shortsViews = await source.GetShortsViewsAsync(); + } + catch (Exception ex) + { + AppLog.Write($"Watch metrics unavailable: {ex.Message}"); + _watchHours = null; + _shortsViews = null; + } + RaiseProgress(); + } + + private void RaiseProgress() + { + OnPropertyChanged(nameof(Eligibility)); + OnPropertyChanged(nameof(HasWatchMetrics)); + OnPropertyChanged(nameof(SubscribersBarWidth)); + OnPropertyChanged(nameof(WatchHoursBarWidth)); + OnPropertyChanged(nameof(ShortsViewsBarWidth)); + OnPropertyChanged(nameof(SubscribersText)); + OnPropertyChanged(nameof(WatchHoursText)); + OnPropertyChanged(nameof(ShortsViewsText)); + OnPropertyChanged(nameof(CriteriaMetText)); + OnPropertyChanged(nameof(ThresholdsAsOfText)); + } + /// Asks YouTube for the channel's recent reward events and keeps only this session's. /// Nothing is written down: the report is a view over YouTube's data, not a local ledger. private async Task RefreshSessionRewardsAsync() @@ -308,6 +387,7 @@ public sealed class YppTrackerViewModel : ViewModelBase IsLoading = true; await RefreshSessionRewardsAsync(); + await RefreshWatchMetricsAsync(); LoadError = null; try { diff --git a/TASKS.md b/TASKS.md index 2ba341b..938a1cc 100644 --- a/TASKS.md +++ b/TASKS.md @@ -54,7 +54,7 @@ blocker; the smallest slice is 32·4 (fragmented MP4 on the record block). | 35 | ☐ | Scene-linked audio | [`task-35`](TASKS/task-35-scene-linked-audio.md) | | 37 | ☐ | Defaults vs current layout split (build-id); enables one-click revert to defaults | [`task-37`](TASKS/task-37-defaults-current-split.md) | | — | ✅ | **Preview undo (Ctrl+Z / Ctrl+Shift+Z)** — done 2026-11-01: `Models/PreviewHistory` + `MainViewModel.Preview`, one step per gesture, gated on `EditingActive`, cleared on layout load. Property edits not yet in the stack | [`ui-surface`](docs/ai/ui-surface.md) | -| 39·2 | ☐ | YPP slice 2: Analytics scopes, watch-hours/Shorts velocity + ETA | [`task-39`](TASKS/task-39-ypp-journey-tracker.md) | +| 39·2 | ✅ | YPP slice 2 — three eligibility bars (subs / watch hours / Shorts views) from Analytics, dated thresholds with a staleness test, "x of 3 criteria met" (done 2026-11-05) | [`task-39`](TASKS/task-39-ypp-journey-tracker.md) | | 49 | ❌ | Chat profanity filter — **retired 2026-11-05**: YouTube owns moderation (blocked words + hold for review run before the API). Replaced by not rendering YouTube's moderation markers (tombstones / no-display events) | [`task-49`](TASKS/task-49-chat-profanity-filter.md) | | 50 | ☐ | One-button publish of the local recording (resumable upload) | [`task-50`](TASKS/task-50-publish-recording.md) | | 54 | 🔶 | Freemium: EULA + privacy shipped; one codebase for free Demo + paid app. **Blocked on Valve**: identity verification pending, tax review up to 30 business days; app IDs, store page and build review all follow | [`task-54`](TASKS/task-54-marcom-free-merch.md) | diff --git a/TASKS/task-39-ypp-journey-tracker.md b/TASKS/task-39-ypp-journey-tracker.md index 65b49a7..2d477ea 100644 --- a/TASKS/task-39-ypp-journey-tracker.md +++ b/TASKS/task-39-ypp-journey-tracker.md @@ -91,7 +91,22 @@ MyMistakes. - 2FA: `https://myaccount.google.com/signinoptions/two-step-verification` - AdSense: `https://support.google.com/youtube/answer/72851` -## Slice 2 (outline, queued) +## Slice 2 — ✅ SHIPPED 2026-11-05 (the three bars) + +Shipped as designed: `yt-analytics.readonly` behind `IWatchMetricsSource`, three bars (subscribers, +long-form watch hours, Shorts views) against `YppThresholds.Tier2Now`, and one line — +"x of 3 criteria met — YouTube reviews eligibility." Notes recorded with it: + +- **The view routes are either/or**, so the line is qualified in the drawer ("subscribers plus watch + hours, or subscribers plus Shorts views"); `YppProgress.MeetsPublishedRule` encodes that shape. +- **Thresholds carry a publication date** (`YppThresholds.AsOfUtc`, shown in the drawer) and a test + fails once that reading is older than 180 days — YouTube moves the bar and a stale bar must not look + current. The 2027-02-01 doubling is data, not a code change. +- **No analytics scope → no invented numbers**: the drawer says what is missing instead of drawing + empty bars as zero. +- Money stays behind the drawer's "Show monetary metrics" toggle (TASK 3·20). + +## Slice 2 (original outline, for reference) `yt-analytics.readonly` (+ re-consent) → `YouTubeAnalyticsService` behind `IChannelStatsProvider` (`ai.md`): long-form `estimatedMinutesWatched` (12-mo), Shorts-feed views (90-d), then velocity / diff --git a/ViewModels/MainViewModel.Ypp.cs b/ViewModels/MainViewModel.Ypp.cs index d1bf507..0aa2320 100644 --- a/ViewModels/MainViewModel.Ypp.cs +++ b/ViewModels/MainViewModel.Ypp.cs @@ -26,7 +26,8 @@ public partial class MainViewModel () => _youtubeAuth.CurrentChannel?.DisplayName, rewardEvents: () => new SuperChatEventService(_youtubeAuth), channelId: () => _youtubeAuth.CurrentChannel?.ChannelId, - sessionStartedAtUtc: () => _sessionStartedAtUtc); + sessionStartedAtUtc: () => _sessionStartedAtUtc, + watchMetrics: () => new YouTubeAnalyticsService(_youtubeAuth)); TestSession = new TestSessionViewModel( _youtubeStream, diff --git a/ytLive.Tests/YppProgressTests.cs b/ytLive.Tests/YppProgressTests.cs new file mode 100644 index 0000000..ab3232c --- /dev/null +++ b/ytLive.Tests/YppProgressTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Linq; +using Xunit; +using ytLive.Models; +using ytLive.Services; + +namespace ytLive.Tests; + +/// +/// TASK 39·2 — the three eligibility bars. The rules worth pinning: the view criteria are EITHER/OR (so +/// "2 of 3" can mean eligible), the long-form bar doubles on 2027-02-01, and the threshold table carries a +/// publication date that fails a test when it goes stale — YouTube moves the goalposts and a stale bar +/// must not look current. +/// +public class YppProgressTests +{ + [Fact] + public void TwoOfThreeCanBeEligible_BecauseTheViewRoutesAreEitherOr() + { + var progress = new YppProgress( + Subscribers: 1_200, SubscriberTarget: 1_000, + WatchHours: 4_500, WatchHoursTarget: 4_000, + ShortsViews: 10_000, ShortsViewsTarget: 10_000_000); + + Assert.Equal(2, progress.CriteriaMet); + Assert.True(progress.MeetsPublishedRule); + Assert.Equal("2 of 3 criteria met — YouTube reviews eligibility.", progress.CriteriaMetText); + } + + [Fact] + public void SubscribersWithoutEitherViewRoute_IsNotEnough() + { + var progress = new YppProgress(2_000, 1_000, 100, 4_000, 5_000, 10_000_000); + + Assert.Equal(1, progress.CriteriaMet); + Assert.False(progress.MeetsPublishedRule); + Assert.Equal("1 of 3 criteria met — YouTube reviews eligibility.", progress.CriteriaMetText); + } + + [Fact] + public void ShortsAlone_IsAQualifyingRoute() + { + var progress = new YppProgress(1_000, 1_000, 0, 4_000, 12_000_000, 10_000_000); + + Assert.Equal(2, progress.CriteriaMet); + Assert.True(progress.MeetsPublishedRule); + } + + [Fact] + public void BarsClampAndNeverOverflow() + { + var progress = new YppProgress(5_000, 1_000, 20_000, 4_000, 50_000_000, 10_000_000); + + Assert.Equal(1.0, progress.SubscribersFraction); + Assert.Equal(1.0, progress.WatchHoursFraction); + Assert.Equal(1.0, progress.ShortsViewsFraction); + Assert.Equal(3, progress.CriteriaMet); + } + + [Fact] + public void TheLongFormBarDoublesOnTheAnnouncedDate() + { + var before = YppThresholds.Tier2For(new DateTime(2027, 1, 31)); + var after = YppThresholds.Tier2For(new DateTime(2027, 2, 1)); + + Assert.Equal(4_000, before.WatchHours); + Assert.Equal(8_000, after.WatchHours); + Assert.Equal(10_000_000, before.ShortsViews); + Assert.Equal(20_000_000, after.ShortsViews); + } + + [Fact] + public void ThresholdsCarryAPublicationDate_ThatFailsWhenStale() + { + // YouTube changes these. If the reading is over 180 days old, the drawer would be presenting a + // stale bar as current — so the suite refuses to pass until someone re-checks the numbers. + var age = DateTime.UtcNow - YppThresholds.AsOfUtc; + Assert.True(age < TimeSpan.FromDays(180), + $"YPP thresholds were last read on {YppThresholds.AsOfUtc:yyyy-MM-dd} — re-check YouTube's published requirements."); + } + + [Fact] + public void AnalyticsResponse_ParsesTheTotal_AndTreatsNoRowsAsNoData() + { + Assert.Equal(123_456, YouTubeAnalyticsService.ParseTotal( + """{"columnHeaders":[{"name":"estimatedMinutesWatched"}],"rows":[[123456]]}""")); + + // A channel with nothing in the window returns no rows: that is "no data", not zero. + Assert.Null(YouTubeAnalyticsService.ParseTotal("""{"columnHeaders":[]}""")); + Assert.Null(YouTubeAnalyticsService.ParseTotal("""{"rows":[]}""")); + } + + [Theory] + [InlineData(640, "640")] + [InlineData(12_345, "12.3k")] + [InlineData(1_234_567, "1.23M")] + public void ValuesStayCompactAndStableInWidth(long value, string expected) + => Assert.Equal(expected, YppProgress.Compact(value)); +}