diff --git a/Services/YouTubeAuthService.cs b/Services/YouTubeAuthService.cs
index bf091cd..1f589c7 100644
--- a/Services/YouTubeAuthService.cs
+++ b/Services/YouTubeAuthService.cs
@@ -33,7 +33,37 @@ public class YouTubeAuthService
/// No forced re-consent: a refresh token keeps the scopes it was originally granted, so an
/// existing session is unaffected; the reduced set applies from the next fresh sign-in.
///
- private const string Scope = "https://www.googleapis.com/auth/youtube";
+ private const string Scope =
+ "https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/yt-analytics.readonly";
+
+ /// Just the Analytics half, so callers can ask "did this session grant it?" without
+ /// string-splitting the whole scope list.
+ internal const string AnalyticsScope = "https://www.googleapis.com/auth/yt-analytics.readonly";
+
+ /// True when the stored session carries the Analytics grant. Tokens saved before the
+ /// scope was added report false, which is what drives the one-click re-consent rather than a
+ /// silent 403 in the middle of a stream.
+ public bool HasAnalyticsScope => GrantedScopes.Contains(AnalyticsScope, StringComparer.Ordinal);
+
+ /// Scopes granted by the CURRENT session, as reported by Google on the token response.
+ /// Empty for a session saved before scopes were recorded — treated as "unknown, assume missing".
+ public IReadOnlyCollection GrantedScopes => _grantedScopes;
+
+ private readonly List _grantedScopes = new();
+
+ /// Records what Google says this token may do. A refresh response carries the granted
+ /// scopes, so a session saved before Analytics was requested reports "not granted" until the
+ /// user re-consents — which is precisely the signal the scoreboard needs.
+ private void CaptureScopes(System.Text.Json.JsonElement tokenData)
+ {
+ _grantedScopes.Clear();
+ if (!tokenData.TryGetProperty("scope", out var scope) || scope.ValueKind != System.Text.Json.JsonValueKind.String)
+ return;
+ var raw = scope.GetString();
+ if (string.IsNullOrWhiteSpace(raw)) return;
+ foreach (var part in raw.Split(' ', System.StringSplitOptions.RemoveEmptyEntries))
+ _grantedScopes.Add(part);
+ }
private readonly string _clientId;
private readonly string _clientSecret;
@@ -91,6 +121,7 @@ public class YouTubeAuthService
("redirect_uri", redirectUri),
("response_type", "code"),
("scope", Scope),
+ ("include_granted_scopes", "true"),
("access_type", "offline"),
("prompt", "consent"),
};
@@ -193,6 +224,7 @@ public class YouTubeAuthService
var tokenData = JsonSerializer.Deserialize(json);
var accessToken = tokenData.GetProperty("access_token").GetString()!;
+ CaptureScopes(tokenData);
var refreshToken = tokenData.GetProperty("refresh_token").GetString()!;
var expiresIn = tokenData.GetProperty("expires_in").GetInt32();
@@ -218,6 +250,7 @@ public class YouTubeAuthService
var json = await response.Content.ReadAsStringAsync();
var tokenData = JsonSerializer.Deserialize(json);
+ CaptureScopes(tokenData);
CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!;
CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32());
_sessionChanged?.Invoke(CurrentChannel);
diff --git a/ytLive.Tests/AnalyticsScopeTests.cs b/ytLive.Tests/AnalyticsScopeTests.cs
new file mode 100644
index 0000000..ccc7617
--- /dev/null
+++ b/ytLive.Tests/AnalyticsScopeTests.cs
@@ -0,0 +1,25 @@
+using Xunit;
+
+namespace ytLive.Tests;
+
+/// Adding the Analytics scope means existing sessions lack it. The app must be able to TELL
+/// — an anonymous 403 mid-stream is the failure this guards against.
+public class AnalyticsScopeTests
+{
+ [Fact]
+ public void The_app_asks_for_analytics_and_can_tell_when_a_session_lacks_it()
+ {
+ var source = SourceAssert.Text("Services", "YouTubeAuthService.cs");
+
+ // Requested, incrementally (so an existing user sees only the new prompt).
+ Assert.Contains("yt-analytics.readonly", source);
+ Assert.Contains("include_granted_scopes", source);
+
+ // And the grant is inspected from the token response, not assumed.
+ Assert.Contains("CaptureScopes", source);
+ Assert.Contains("HasAnalyticsScope", source);
+
+ // A connection type that exposes the flag, so the scoreboard can offer the one-click path.
+ Assert.NotNull(typeof(ytLive.Services.YouTubeAuthService).GetProperty("HasAnalyticsScope"));
+ }
+}
diff --git a/ytLive.Tests/YouTubeAuthServiceTests.cs b/ytLive.Tests/YouTubeAuthServiceTests.cs
index 955954f..1dc03b6 100644
--- a/ytLive.Tests/YouTubeAuthServiceTests.cs
+++ b/ytLive.Tests/YouTubeAuthServiceTests.cs
@@ -138,10 +138,17 @@ public class YouTubeAuthServiceTests
/// documenting the guarantee gets deleted instead of obeyed.
///
[Fact]
- public void ExactlyOneScopeIsRequested_AndItIsTheBroadOne()
+ public void TwoScopesAreRequested_TheBroadYouTubeOneAndReadOnlyAnalytics()
{
+ // Updated 2026-10-30: the scoreboard's two polled tiles need watch-time analytics, so the
+ // app asks for exactly one more scope. The DECISION worth locking is still "no redundant
+ // scopes": force-ssl stays gone, and analytics is read-only.
Assert.Equal(
- new[] { "https://www.googleapis.com/auth/youtube" },
+ new[]
+ {
+ "https://www.googleapis.com/auth/youtube",
+ "https://www.googleapis.com/auth/yt-analytics.readonly",
+ },
SourceAssert.Scopes("Scope", "Services", "YouTubeAuthService.cs"));
}