OAuth session persists via DPAPI; sign-in lives in the Start Stream dialog; add xUnit test project (6 passing)
This commit is contained in:
+64
-3
@@ -1,7 +1,7 @@
|
|||||||
<Window x:Class="ytLive.GoLiveWindow"
|
<Window x:Class="ytLive.GoLiveWindow"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
Title="Start Stream" Height="430" Width="520"
|
Title="Start Stream" Height="480" Width="520"
|
||||||
Icon="/Assets/llama-logo-icon.png"
|
Icon="/Assets/llama-logo-icon.png"
|
||||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
WindowStartupLocation="CenterOwner" ResizeMode="NoResize"
|
||||||
ShowInTaskbar="False" Background="#1a1a2e">
|
ShowInTaskbar="False" Background="#1a1a2e">
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
<Grid Margin="24">
|
<Grid Margin="24">
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
@@ -23,8 +24,68 @@
|
|||||||
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
|
<TextBlock Grid.Row="0" Text="Start Stream" FontSize="18" FontWeight="Bold"
|
||||||
Foreground="#e0e0e0" Margin="0,0,0,16"/>
|
Foreground="#e0e0e0" Margin="0,0,0,16"/>
|
||||||
|
|
||||||
|
<!-- ACCOUNT -->
|
||||||
|
<StackPanel Grid.Row="1" Margin="0,0,0,16">
|
||||||
|
<TextBlock Text="ACCOUNT" FontSize="12" FontWeight="SemiBold"
|
||||||
|
Foreground="#a0a0b0" Margin="0,0,0,8"/>
|
||||||
|
|
||||||
|
<!-- signed in -->
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<StackPanel.Style>
|
||||||
|
<Style TargetType="StackPanel">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsSignedIn}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</StackPanel.Style>
|
||||||
|
<Border Width="34" Height="34" CornerRadius="17" Background="#16213e" ClipToBounds="True"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,10,0">
|
||||||
|
<Image Source="{Binding AccountAvatarUrl}" Stretch="UniformToFill"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Text="{Binding AccountDisplayName}" Foreground="#e0e0e0" FontSize="14"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,12,0"/>
|
||||||
|
<Button Content="Change Account" Command="{Binding SignInCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- signed out -->
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<StackPanel.Style>
|
||||||
|
<Style TargetType="StackPanel">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsSignedIn}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</StackPanel.Style>
|
||||||
|
<Button Content="Sign in to YouTube" Command="{Binding SignInCommand}"
|
||||||
|
Style="{StaticResource YtButtonSecondary}" Margin="0,0,10,0"/>
|
||||||
|
<TextBlock Text="Going live requires a YouTube account." Foreground="#a0a0b0" FontSize="12"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="Signing in… open the browser to authorize, then come back here."
|
||||||
|
Foreground="#e94560" FontSize="12" Margin="0,8,0,0">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsBusy}" Value="True">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
<!-- STREAM METADATA -->
|
<!-- STREAM METADATA -->
|
||||||
<StackPanel Grid.Row="1">
|
<StackPanel Grid.Row="2">
|
||||||
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
|
<TextBlock Text="STREAM DETAILS" FontSize="12" FontWeight="SemiBold"
|
||||||
Foreground="#a0a0b0" Margin="0,0,0,8"/>
|
Foreground="#a0a0b0" Margin="0,0,0,8"/>
|
||||||
|
|
||||||
@@ -44,7 +105,7 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- ACTIONS -->
|
<!-- ACTIONS -->
|
||||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
<Button Content="Cancel" Command="{Binding CancelCommand}"
|
||||||
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
Style="{StaticResource YtButtonSecondary}" Margin="0,0,8,0"/>
|
||||||
<Button Content="Start Stream" Command="{Binding StartCommand}"
|
<Button Content="Start Stream" Command="{Binding StartCommand}"
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using System.IO;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the OAuth session (channel + tokens) to a DPAPI-protected file in
|
||||||
|
/// %APPDATA%\ytLlive\ytLlive.auth so sign-in survives restarts. The whole
|
||||||
|
/// payload is protected with DataProtectionScope.CurrentUser — only the
|
||||||
|
/// signed-in Windows user can decrypt it.
|
||||||
|
/// </summary>
|
||||||
|
public static class TokenStore
|
||||||
|
{
|
||||||
|
public static string DefaultPath => Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
|
"ytLlive",
|
||||||
|
"ytLlive.auth");
|
||||||
|
|
||||||
|
public static void Save(YouTubeChannel channel, string? path = null)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(channel);
|
||||||
|
var plain = System.Text.Encoding.UTF8.GetBytes(json);
|
||||||
|
var protectedBytes = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
|
||||||
|
|
||||||
|
var file = path ?? DefaultPath;
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(file)!);
|
||||||
|
File.WriteAllBytes(file, protectedBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static YouTubeChannel? Load(string? path = null)
|
||||||
|
{
|
||||||
|
var file = path ?? DefaultPath;
|
||||||
|
if (!File.Exists(file)) return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var protectedBytes = File.ReadAllBytes(file);
|
||||||
|
var plain = ProtectedData.Unprotect(protectedBytes, null, DataProtectionScope.CurrentUser);
|
||||||
|
var json = System.Text.Encoding.UTF8.GetString(plain);
|
||||||
|
return JsonSerializer.Deserialize<YouTubeChannel>(json);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Clear(string? path = null)
|
||||||
|
{
|
||||||
|
var file = path ?? DefaultPath;
|
||||||
|
if (File.Exists(file)) File.Delete(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ Cross-cutting utilities. See [`schema.md`](../schema.md) for the memory-map conv
|
|||||||
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for) |
|
| `AppLog.cs` | File logger to `%APPDATA%\ytLlive\startup.log`; startup checkpoints + unhandled-exception capture (the crash this map is named for) |
|
||||||
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
|
| `FocusPreservingListBox.cs` | `ListBox` subclass that keeps selection/focus coherent when the selected item is deleted; `IsSelectable` predicate skips hidden scenes; drag-reorder guard |
|
||||||
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
|
| `OAuthCredentials.cs` | Baked-in Google OAuth client ID/secret (desktop app; loopback callback) |
|
||||||
|
| `TokenStore.cs` | DPAPI-protected OAuth session persistence (`%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope); `Save`/`Load`/`Clear` — sign-in survives restarts |
|
||||||
| `ImageCache.cs` | Image byte caching (assets live in the DB) |
|
| `ImageCache.cs` | Image byte caching (assets live in the DB) |
|
||||||
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
|
| `InverseBoolToVisibilityConverter.cs` / `NotNullToVisibilityConverter.cs` | XAML value converters for visibility bindings |
|
||||||
|
|
||||||
|
|||||||
@@ -31,29 +31,6 @@
|
|||||||
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"
|
<Helpers:NotNullToVisibilityConverter x:Key="NotNullToVis"
|
||||||
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
|
xmlns:Helpers="clr-namespace:ytLive.Helpers"/>
|
||||||
|
|
||||||
<!-- YouTube logo shown on the Connect button -->
|
|
||||||
<DrawingImage x:Key="YoutubeLogo">
|
|
||||||
<DrawingImage.Drawing>
|
|
||||||
<DrawingGroup>
|
|
||||||
<GeometryDrawing Brush="#FF0000">
|
|
||||||
<GeometryDrawing.Geometry>
|
|
||||||
<RectangleGeometry Rect="0,0,66,47" RadiusX="10" RadiusY="10"/>
|
|
||||||
</GeometryDrawing.Geometry>
|
|
||||||
</GeometryDrawing>
|
|
||||||
<GeometryDrawing Brush="White">
|
|
||||||
<GeometryDrawing.Geometry>
|
|
||||||
<PathGeometry>
|
|
||||||
<PathFigure IsClosed="True" StartPoint="25,14">
|
|
||||||
<LineSegment Point="25,33"/>
|
|
||||||
<LineSegment Point="45,23.5"/>
|
|
||||||
</PathFigure>
|
|
||||||
</PathGeometry>
|
|
||||||
</GeometryDrawing.Geometry>
|
|
||||||
</GeometryDrawing>
|
|
||||||
</DrawingGroup>
|
|
||||||
</DrawingImage.Drawing>
|
|
||||||
</DrawingImage>
|
|
||||||
|
|
||||||
<!-- Red dot shown in the taskbar icon while live -->
|
<!-- Red dot shown in the taskbar icon while live -->
|
||||||
<DrawingImage x:Key="LiveOverlay">
|
<DrawingImage x:Key="LiveOverlay">
|
||||||
<DrawingImage.Drawing>
|
<DrawingImage.Drawing>
|
||||||
@@ -123,14 +100,6 @@
|
|||||||
|
|
||||||
<!-- Single three-state action button -->
|
<!-- Single three-state action button -->
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
|
||||||
<Button Style="{StaticResource YtButtonSecondary}" Command="{Binding ConnectCommand}"
|
|
||||||
Visibility="{Binding IsNotConnected, Converter={StaticResource BoolToVis}}">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<Image Source="{StaticResource YoutubeLogo}" Width="22" Height="16"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Text="Connect" Margin="8,0,0,0" VerticalAlignment="Center"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button Content="Start Stream" Style="{StaticResource YtButton}"
|
<Button Content="Start Stream" Style="{StaticResource YtButton}"
|
||||||
Command="{Binding StartStreamCommand}"
|
Command="{Binding StartStreamCommand}"
|
||||||
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/>
|
Visibility="{Binding ShowStartStream, Converter={StaticResource BoolToVis}}"/>
|
||||||
|
|||||||
@@ -19,14 +19,24 @@ public class YouTubeAuthService
|
|||||||
|
|
||||||
private readonly string _clientId;
|
private readonly string _clientId;
|
||||||
private readonly string _clientSecret;
|
private readonly string _clientSecret;
|
||||||
private readonly HttpClient _http = new();
|
private readonly HttpClient _http;
|
||||||
|
private readonly Action<YouTubeChannel>? _sessionChanged;
|
||||||
|
|
||||||
public YouTubeChannel? CurrentChannel { get; private set; }
|
public YouTubeChannel? CurrentChannel { get; private set; }
|
||||||
|
|
||||||
public YouTubeAuthService(string clientId, string clientSecret)
|
public YouTubeAuthService(string clientId, string clientSecret, HttpClient? http = null,
|
||||||
|
Action<YouTubeChannel>? sessionChanged = null)
|
||||||
{
|
{
|
||||||
_clientId = clientId;
|
_clientId = clientId;
|
||||||
_clientSecret = clientSecret;
|
_clientSecret = clientSecret;
|
||||||
|
_http = http ?? new HttpClient();
|
||||||
|
_sessionChanged = sessionChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Replaces the current session (used when a saved token loads at startup).</summary>
|
||||||
|
public void SetSession(YouTubeChannel channel)
|
||||||
|
{
|
||||||
|
CurrentChannel = channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GetAuthorizationUrl(string redirectUri)
|
public string GetAuthorizationUrl(string redirectUri)
|
||||||
@@ -136,6 +146,7 @@ public class YouTubeAuthService
|
|||||||
|
|
||||||
CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!;
|
CurrentChannel.AccessToken = tokenData.GetProperty("access_token").GetString()!;
|
||||||
CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32());
|
CurrentChannel.TokenExpiry = DateTime.UtcNow.AddSeconds(tokenData.GetProperty("expires_in").GetInt32());
|
||||||
|
_sessionChanged?.Invoke(CurrentChannel);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +173,7 @@ public class YouTubeAuthService
|
|||||||
TokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn)
|
TokenExpiry = DateTime.UtcNow.AddSeconds(expiresIn)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_sessionChanged?.Invoke(CurrentChannel);
|
||||||
return CurrentChannel;
|
return CurrentChannel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ External-facing logic: YouTube API, persistence. See
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. **Complete — but no token persistence yet** (DPAPI planned) |
|
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI) |
|
||||||
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** |
|
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API (`enableAutoStart/Stop`). **Not yet switched to the `variable` reusable stream** |
|
||||||
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
|
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
|
||||||
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files |
|
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files |
|
||||||
|
|||||||
@@ -72,11 +72,12 @@ Lifecycle: `created → ready → [testing] → live → complete` (transitional
|
|||||||
- Verify token refresh triggers when near expiry
|
- Verify token refresh triggers when near expiry
|
||||||
- Verify credential load/save roundtrip
|
- Verify credential load/save roundtrip
|
||||||
|
|
||||||
### Status: 🔶 UI flow done — auth wiring pending
|
### Status: ✅ Complete
|
||||||
- ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
|
- ✅ Two-state Start/End Stream button, go-live dialog (account + title/description/visibility), red top bar, pulsing LIVE badge + elapsed timer, preview glow, taskbar red dot
|
||||||
- ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch
|
- ✅ Real OAuth2 wiring — baked-in Google credentials (desktop client; loopback callback) + `YouTubeAuthService` complete: browser launch, `HttpListener` callback, token exchange, refresh, channel fetch
|
||||||
- ⬜ Token persistence via Windows DPAPI, reload on startup
|
- ✅ Token persistence via Windows DPAPI (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`), best-effort reload + proactive refresh at startup, saved after every exchange/refresh
|
||||||
- ⬜ Account sign-in/change surfaced in the GoLive dialog (GoLiveViewModel still simulates it)
|
- ✅ Account sign-in/change surfaced in the GoLive dialog (saved account shown with "Change Account"; "Sign in to YouTube" when none; Start disabled until signed in)
|
||||||
|
- ✅ Tests in `ytLive.Tests` (xUnit, net8.0-windows): TokenStore DPAPI roundtrip/corrupt/missing/clear + mocked exchange channel-parse + refresh expiry bump — 6 passing
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
using System.Windows.Input;
|
using System.Windows.Input;
|
||||||
using ytLive.Helpers;
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
namespace ytLive.ViewModels;
|
namespace ytLive.ViewModels;
|
||||||
|
|
||||||
public class GoLiveViewModel : ViewModelBase
|
public class GoLiveViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
|
private readonly Func<Task<YouTubeChannel?>> _signInProvider;
|
||||||
private string _streamTitle = string.Empty;
|
private string _streamTitle = string.Empty;
|
||||||
private string _streamDescription = string.Empty;
|
private string _streamDescription = string.Empty;
|
||||||
private string _visibility = "Public";
|
private string _visibility = "Public";
|
||||||
|
private bool _isSignedIn;
|
||||||
|
private string _accountDisplayName = string.Empty;
|
||||||
|
private string _accountAvatarUrl = string.Empty;
|
||||||
|
private bool _isBusy;
|
||||||
|
|
||||||
|
public GoLiveViewModel(Func<Task<YouTubeChannel?>> signInProvider, YouTubeChannel? account)
|
||||||
|
{
|
||||||
|
_signInProvider = signInProvider;
|
||||||
|
|
||||||
|
SignInCommand = new RelayCommand(_ => _ = SignInAsync());
|
||||||
|
StartCommand = new RelayCommand(_ => StartRequested?.Invoke(), _ => IsSignedIn && !IsBusy);
|
||||||
|
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke(), _ => !IsBusy);
|
||||||
|
|
||||||
|
if (account != null)
|
||||||
|
ApplyAccount(account);
|
||||||
|
}
|
||||||
|
|
||||||
public string StreamTitle
|
public string StreamTitle
|
||||||
{
|
{
|
||||||
@@ -29,15 +47,65 @@ public class GoLiveViewModel : ViewModelBase
|
|||||||
|
|
||||||
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
|
public string[] Visibilities { get; } = { "Public", "Unlisted", "Private" };
|
||||||
|
|
||||||
|
public bool IsSignedIn
|
||||||
|
{
|
||||||
|
get => _isSignedIn;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isSignedIn, value))
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AccountDisplayName
|
||||||
|
{
|
||||||
|
get => _accountDisplayName;
|
||||||
|
private set => SetProperty(ref _accountDisplayName, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string AccountAvatarUrl
|
||||||
|
{
|
||||||
|
get => _accountAvatarUrl;
|
||||||
|
private set => SetProperty(ref _accountAvatarUrl, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBusy
|
||||||
|
{
|
||||||
|
get => _isBusy;
|
||||||
|
private set
|
||||||
|
{
|
||||||
|
if (SetProperty(ref _isBusy, value))
|
||||||
|
CommandManager.InvalidateRequerySuggested();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ICommand SignInCommand { get; }
|
||||||
public ICommand StartCommand { get; }
|
public ICommand StartCommand { get; }
|
||||||
public ICommand CancelCommand { get; }
|
public ICommand CancelCommand { get; }
|
||||||
|
|
||||||
public GoLiveViewModel()
|
|
||||||
{
|
|
||||||
StartCommand = new RelayCommand(_ => StartRequested?.Invoke());
|
|
||||||
CancelCommand = new RelayCommand(_ => CancelRequested?.Invoke());
|
|
||||||
}
|
|
||||||
|
|
||||||
public event Action? StartRequested;
|
public event Action? StartRequested;
|
||||||
public event Action? CancelRequested;
|
public event Action? CancelRequested;
|
||||||
|
|
||||||
|
private async Task SignInAsync()
|
||||||
|
{
|
||||||
|
if (IsBusy) return;
|
||||||
|
IsBusy = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var channel = await _signInProvider();
|
||||||
|
if (channel != null)
|
||||||
|
ApplyAccount(channel);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyAccount(YouTubeChannel channel)
|
||||||
|
{
|
||||||
|
AccountDisplayName = channel.DisplayName;
|
||||||
|
AccountAvatarUrl = channel.ProfileImageUrl;
|
||||||
|
IsSignedIn = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-11
@@ -126,18 +126,14 @@ public class MainViewModel : ViewModelBase
|
|||||||
set
|
set
|
||||||
{
|
{
|
||||||
if (SetProperty(ref _isConnected, value))
|
if (SetProperty(ref _isConnected, value))
|
||||||
{
|
|
||||||
OnPropertyChanged(nameof(IsNotConnected));
|
|
||||||
OnPropertyChanged(nameof(ShowStartStream));
|
OnPropertyChanged(nameof(ShowStartStream));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
public bool IsOffline => StreamStatus == StreamStatus.Offline;
|
||||||
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
public bool IsLive => StreamStatus == StreamStatus.Streaming;
|
||||||
public bool LiveIndicatorVisible => IsLive;
|
public bool LiveIndicatorVisible => IsLive;
|
||||||
public bool IsNotConnected => !IsConnected;
|
public bool ShowStartStream => IsOffline;
|
||||||
public bool ShowStartStream => IsConnected && IsOffline;
|
|
||||||
public bool ShowChatInactiveMessage => !IsLive;
|
public bool ShowChatInactiveMessage => !IsLive;
|
||||||
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0;
|
public bool ShowEmptySceneHint => ActiveScene != null && ActiveScene.Sources.Count == 0;
|
||||||
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
|
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null;
|
||||||
@@ -406,7 +402,6 @@ public class MainViewModel : ViewModelBase
|
|||||||
public ICommand AddSourceCommand { get; }
|
public ICommand AddSourceCommand { get; }
|
||||||
public ICommand AddImageCommand { get; }
|
public ICommand AddImageCommand { get; }
|
||||||
public ICommand RemoveSourceCommand { get; }
|
public ICommand RemoveSourceCommand { get; }
|
||||||
public ICommand ConnectCommand { get; }
|
|
||||||
public ICommand StartStreamCommand { get; }
|
public ICommand StartStreamCommand { get; }
|
||||||
public ICommand EndStreamCommand { get; }
|
public ICommand EndStreamCommand { get; }
|
||||||
public ICommand OpenSettingsCommand { get; }
|
public ICommand OpenSettingsCommand { get; }
|
||||||
@@ -424,7 +419,8 @@ public class MainViewModel : ViewModelBase
|
|||||||
public MainViewModel()
|
public MainViewModel()
|
||||||
{
|
{
|
||||||
AppLog.Write("MainViewModel ctor begin");
|
AppLog.Write("MainViewModel ctor begin");
|
||||||
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret);
|
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
|
||||||
|
sessionChanged: ch => TokenStore.Save(ch));
|
||||||
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
|
_youtubeStream = new YouTubeStreamService(_youtubeAuth);
|
||||||
_youtubeChat = new YouTubeChatService(_youtubeAuth);
|
_youtubeChat = new YouTubeChatService(_youtubeAuth);
|
||||||
AppLog.Write("MainViewModel ctor: services created");
|
AppLog.Write("MainViewModel ctor: services created");
|
||||||
@@ -462,7 +458,6 @@ public class MainViewModel : ViewModelBase
|
|||||||
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
|
SubmitBugCommand = new RelayCommand(_ => SubmitBug());
|
||||||
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
|
SubmitFeatureCommand = new RelayCommand(_ => SubmitFeature());
|
||||||
OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
|
OpenChannelCommand = new RelayCommand(_ => OpenUrl(ChannelUrl));
|
||||||
ConnectCommand = new RelayCommand(_ => _ = ConnectAsync());
|
|
||||||
StartStreamCommand = new RelayCommand(_ => BeginGoLive());
|
StartStreamCommand = new RelayCommand(_ => BeginGoLive());
|
||||||
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
|
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive);
|
||||||
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
SaveLayoutCommand = new RelayCommand(_ => SaveLayoutNow());
|
||||||
@@ -472,9 +467,40 @@ public class MainViewModel : ViewModelBase
|
|||||||
_layoutStore = new LayoutStore(DefaultLayoutPath);
|
_layoutStore = new LayoutStore(DefaultLayoutPath);
|
||||||
_activeLayoutPath = _layoutStore.ActivePath;
|
_activeLayoutPath = _layoutStore.ActivePath;
|
||||||
LoadLayout();
|
LoadLayout();
|
||||||
|
_ = LoadSavedSessionAsync();
|
||||||
AppLog.Write("MainViewModel ctor end");
|
AppLog.Write("MainViewModel ctor end");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restores the DPAPI-saved OAuth session so sign-in survives restarts.
|
||||||
|
// Best-effort: refresh a near-expiry access token; a session that can no
|
||||||
|
// longer refresh is discarded and the user signs in again.
|
||||||
|
private async Task LoadSavedSessionAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var saved = TokenStore.Load();
|
||||||
|
if (saved == null) return;
|
||||||
|
|
||||||
|
_youtubeAuth.SetSession(saved);
|
||||||
|
if (saved.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
|
||||||
|
{
|
||||||
|
var refreshed = await Task.Run(() => _youtubeAuth.RefreshToken());
|
||||||
|
if (!refreshed)
|
||||||
|
{
|
||||||
|
TokenStore.Clear();
|
||||||
|
AppLog.Write("Saved session could not be refreshed; signed out");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IsConnected = true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
AppLog.Write($"LoadSavedSessionAsync failed: {ex}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string DefaultLayoutPath => Path.Combine(
|
private static string DefaultLayoutPath => Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||||
"ytLlive",
|
"ytLlive",
|
||||||
@@ -870,7 +896,10 @@ public class MainViewModel : ViewModelBase
|
|||||||
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ConnectAsync()
|
// Runs the OAuth flow (browser + loopback callback) on a background thread.
|
||||||
|
// The resulting channel is returned to the caller and persisted via the
|
||||||
|
// auth service's sessionChanged hook.
|
||||||
|
private async Task<YouTubeChannel?> SignInAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -879,15 +908,17 @@ public class MainViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
|
MessageBox.Show("Sign-in was unsuccessful. Please try again.", "ytLlive",
|
||||||
MessageBoxButton.OK, MessageBoxImage.Warning);
|
MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
IsConnected = true;
|
IsConnected = true;
|
||||||
|
return channel;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
|
MessageBox.Show($"Sign-in failed: {ex.Message}", "ytLlive",
|
||||||
MessageBoxButton.OK, MessageBoxImage.Error);
|
MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -903,7 +934,7 @@ public class MainViewModel : ViewModelBase
|
|||||||
|
|
||||||
private void BeginGoLive()
|
private void BeginGoLive()
|
||||||
{
|
{
|
||||||
var dialog = new GoLiveViewModel
|
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
|
||||||
{
|
{
|
||||||
StreamTitle = DefaultStreamTitle,
|
StreamTitle = DefaultStreamTitle,
|
||||||
StreamDescription = DefaultStreamDescription,
|
StreamDescription = DefaultStreamDescription,
|
||||||
|
|||||||
+2
-2
@@ -4,8 +4,8 @@ MVVM layer. See [`schema.md`](../schema.md) for the memory-map conventions.
|
|||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`) |
|
| `MainViewModel.cs` | The app brain: scenes/sources collections + commands, stream state (`IsLive`/`IsOffline`/`IsConnected`), chat feed, overlays, layout save/open, **resolution dropdown** (`QualityOptions`, `SelectedQuality`, `ResolutionHelp`, `ApplyStreamQuality`) that computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). Auth: loads the saved DPAPI session at startup (`LoadSavedSessionAsync`), `SignInAsync` feeds the GoLive dialog |
|
||||||
| `GoLiveViewModel.cs` | Start Stream dialog: title/description/visibility, start/cancel requests |
|
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description/visibility, start/cancel requests |
|
||||||
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
|
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
|
||||||
|
|
||||||
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
|
Related: [`Models/index.md`](../Models/index.md) for the data; [`Services/index.md`](../Services/index.md)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ C# / WPF (.NET 8) following MVVM:
|
|||||||
| `Models/` | Plain data types — Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
|
| `Models/` | Plain data types — Scene, Source, QualityOption, StreamConfig, StreamHealth, YouTubeChannel, ChatMessage |
|
||||||
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel |
|
| `ViewModels/` | MainViewModel — exposes collections + commands for the UI; GoLiveViewModel, ReuseImageViewModel |
|
||||||
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) |
|
| `Services/` | YouTube OAuth2, stream/broadcast management, live chat polling, LayoutStore (SQLite) |
|
||||||
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, visibility converters |
|
| `Helpers/` | ViewModelBase (INotifyPropertyChanged), RelayCommand, ImageCache, AppLog (file logger), FocusPreservingListBox, OAuthCredentials, **TokenStore (DPAPI session persistence)**, visibility converters |
|
||||||
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
|
| `Themes/` | `Controls.xaml` — the single dark-theme source, merged once in `App.xaml` (see `Themes/index.md`) |
|
||||||
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
|
| `MainWindow.xaml` | Dark theme; layout: top bar (controls), center (preview), left (scenes/sources), right (chat), bottom (health) |
|
||||||
|
|
||||||
@@ -64,9 +64,8 @@ C# / WPF (.NET 8) following MVVM:
|
|||||||
|
|
||||||
### Current limitations / TODOs
|
### Current limitations / TODOs
|
||||||
|
|
||||||
- `Helpers/OAuthCredentials.cs` now contains the real ClientId/ClientSecret — auth service is implemented, but **tokens still don't persist** (Windows DPAPI planned; account UI in the GoLive dialog is simulated)
|
- `Helpers/OAuthCredentials.cs` contains the real ClientId/ClientSecret. Auth is complete and the session **persists via Windows DPAPI** (`Helpers/TokenStore.cs` → `%APPDATA%\ytLlive\ytLlive.auth`, CurrentUser scope), reloaded best-effort at startup with a proactive refresh of a near-expiry access token. Sign-in/Change Account lives **inside the Start Stream dialog** (two-state flow — no separate Connect button). `YouTubeAuthService` takes an optional `HttpClient` + `sessionChanged` callback (test seam + save hook; services are still constructed in `MainViewModel`)
|
||||||
- `GoLiveViewModel.SignIn`/`ChangeAccount` removed — Connect (OAuth) is the only entry to streaming
|
- Scene/source/asset layout persists (SQLite); the OAuth session persists (DPAPI); the paid-unlock state does not (yet — itch.io key verification pending)
|
||||||
- Scene/source/asset layout *does* persist (SQLite); token persistence does not (yet)
|
|
||||||
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
|
- `YouTubeStreamService` uses hardcoded `1080p`/`60fps` and per-broadcast streams — must switch to the v3 `variable` reusable stream
|
||||||
- No capture/encoding/RTMP yet
|
- No capture/encoding/RTMP yet
|
||||||
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
|
- `StreamConfig` defaults (`TargetBitrate=6000`, `Resolution="1920x1080"`) are stale — the live dropdown drives `StreamHealth.CurrentBitrate`/`FPS` instead
|
||||||
@@ -106,16 +105,19 @@ resolution & streaming-constraints conversation, not monetization.
|
|||||||
|
|
||||||
The app is fully usable without authentication: users can build scenes, add sources, compose
|
The app is fully usable without authentication: users can build scenes, add sources, compose
|
||||||
previews, and audition the software with zero commitment. But **going live requires authentication** —
|
previews, and audition the software with zero commitment. But **going live requires authentication** —
|
||||||
it's the one capability gated behind YouTube sign-in. The sign-in button should never pressure the
|
it's the one capability gated behind YouTube sign-in. The sign-in should never pressure the user
|
||||||
user ("sign in (optional)", not a modal wall), but "Go Live" only appears once connected.
|
("sign in (optional)", not a modal wall): the two-state top bar shows **Start Stream** (offline) /
|
||||||
|
**End Stream** (live), and the Start Stream dialog hosts the account — a saved session appears as
|
||||||
|
the default with "Change Account"; with none saved, a "Sign in to YouTube" button starts OAuth and
|
||||||
|
the Start button stays disabled until signed in.
|
||||||
|
|
||||||
## Account assumption (do not build an account setup flow)
|
## Account assumption (do not build an account setup flow)
|
||||||
|
|
||||||
Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator
|
Connecting uses Google OAuth ("Sign in with Google") to link an **existing** YouTube creator
|
||||||
account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no
|
account. ytLlive **never creates or sets up accounts** — that is YouTube's job. If the creator has no
|
||||||
YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently
|
YouTube channel, they go to YouTube first. This assumption is explicit and must never be silently
|
||||||
replaced by an in-app account-creation step. Zero state = a Connect button that starts OAuth; going
|
replaced by an in-app account-creation step. Zero state = the Start Stream dialog's "Sign in to
|
||||||
live is unreachable until the account is connected.
|
YouTube" button; going live is unreachable until an account is connected.
|
||||||
|
|
||||||
## YouTube Live API — design constraints (do not violate)
|
## YouTube Live API — design constraints (do not violate)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using System.IO;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Helpers;
|
||||||
|
using ytLive.Models;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
public class TokenStoreTests
|
||||||
|
{
|
||||||
|
private static string TempPath() =>
|
||||||
|
Path.Combine(Path.GetTempPath(), "ytLive_tests", $"{Guid.NewGuid():N}.auth");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Save_Load_RoundtripsChannelAndTokens()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
var channel = new YouTubeChannel
|
||||||
|
{
|
||||||
|
ChannelId = "UC123",
|
||||||
|
DisplayName = "Test Channel",
|
||||||
|
ProfileImageUrl = "http://example/thumb.jpg",
|
||||||
|
AccessToken = "acc-token",
|
||||||
|
RefreshToken = "refresh-token",
|
||||||
|
TokenExpiry = DateTime.UtcNow.AddHours(1),
|
||||||
|
};
|
||||||
|
|
||||||
|
TokenStore.Save(channel, path);
|
||||||
|
|
||||||
|
var loaded = TokenStore.Load(path);
|
||||||
|
Assert.NotNull(loaded);
|
||||||
|
Assert.Equal("UC123", loaded!.ChannelId);
|
||||||
|
Assert.Equal("Test Channel", loaded.DisplayName);
|
||||||
|
Assert.Equal("http://example/thumb.jpg", loaded.ProfileImageUrl);
|
||||||
|
Assert.Equal("acc-token", loaded.AccessToken);
|
||||||
|
Assert.Equal("refresh-token", loaded.RefreshToken);
|
||||||
|
Assert.Equal(channel.TokenExpiry, loaded.TokenExpiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_MissingFile_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(TokenStore.Load(TempPath()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Load_CorruptFile_ReturnsNull()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllBytes(path, [1, 2, 3, 4, 5]);
|
||||||
|
Assert.Null(TokenStore.Load(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clear_RemovesSavedToken()
|
||||||
|
{
|
||||||
|
var path = TempPath();
|
||||||
|
TokenStore.Save(new YouTubeChannel { DisplayName = "X" }, path);
|
||||||
|
TokenStore.Clear(path);
|
||||||
|
Assert.Null(TokenStore.Load(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Xunit;
|
||||||
|
using ytLive.Models;
|
||||||
|
using ytLive.Services;
|
||||||
|
|
||||||
|
namespace ytLive.Tests;
|
||||||
|
|
||||||
|
public class YouTubeAuthServiceTests
|
||||||
|
{
|
||||||
|
private sealed class FakeHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly string _tokenResponse;
|
||||||
|
private readonly string _channelResponse;
|
||||||
|
|
||||||
|
public FakeHandler(string tokenResponse, string channelResponse)
|
||||||
|
{
|
||||||
|
_tokenResponse = tokenResponse;
|
||||||
|
_channelResponse = channelResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var isToken = request.RequestUri!.PathAndQuery.Contains("/token");
|
||||||
|
var body = isToken ? _tokenResponse : _channelResponse;
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body, Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static YouTubeAuthService CreateService(string tokenResponse, string channelResponse) =>
|
||||||
|
new("test-id", "test-secret", new HttpClient(new FakeHandler(tokenResponse, channelResponse)));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExchangeCodeForToken_MockServer_ParsesChannel()
|
||||||
|
{
|
||||||
|
var service = CreateService(
|
||||||
|
"""{"access_token":"acc-123","refresh_token":"ref-123","expires_in":3600}""",
|
||||||
|
"""{"items":[{"id":"UC123","snippet":{"title":"Test Channel","thumbnails":{"default":{"url":"http://example/thumb.jpg"}}}}]}""");
|
||||||
|
|
||||||
|
var channel = await service.ExchangeCodeForToken("auth-code", "http://localhost:8765/oauth2/callback");
|
||||||
|
|
||||||
|
Assert.NotNull(channel);
|
||||||
|
Assert.Equal("UC123", channel!.ChannelId);
|
||||||
|
Assert.Equal("Test Channel", channel.DisplayName);
|
||||||
|
Assert.Equal("http://example/thumb.jpg", channel.ProfileImageUrl);
|
||||||
|
Assert.Equal("acc-123", channel.AccessToken);
|
||||||
|
Assert.Equal("ref-123", channel.RefreshToken);
|
||||||
|
Assert.InRange(channel.TokenExpiry, DateTime.UtcNow.AddSeconds(3590), DateTime.UtcNow.AddSeconds(3610));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshToken_MockServer_UpdatesAccessTokenAndExpiry()
|
||||||
|
{
|
||||||
|
var service = CreateService(
|
||||||
|
"""{"access_token":"new-acc","expires_in":3600}""",
|
||||||
|
"""{"items":[]}""");
|
||||||
|
service.SetSession(new YouTubeChannel
|
||||||
|
{
|
||||||
|
RefreshToken = "ref-123",
|
||||||
|
TokenExpiry = DateTime.UtcNow.AddMinutes(-5),
|
||||||
|
});
|
||||||
|
|
||||||
|
var ok = await service.RefreshToken();
|
||||||
|
|
||||||
|
Assert.True(ok);
|
||||||
|
Assert.Equal("new-acc", service.CurrentChannel!.AccessToken);
|
||||||
|
Assert.InRange(service.CurrentChannel.TokenExpiry,
|
||||||
|
DateTime.UtcNow.AddSeconds(3590), DateTime.UtcNow.AddSeconds(3610));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0-windows</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1"/>
|
||||||
|
<PackageReference Include="xunit" Version="2.9.2"/>
|
||||||
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ytLive.csproj"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -12,6 +12,13 @@
|
|||||||
<RootNamespace>ytLive</RootNamespace>
|
<RootNamespace>ytLive</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="ytLive.Tests\**"/>
|
||||||
|
<EmbeddedResource Remove="ytLive.Tests\**"/>
|
||||||
|
<None Remove="ytLive.Tests\**"/>
|
||||||
|
<Page Remove="ytLive.Tests\**"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Resource Include="Assets\llama-logo.png"/>
|
<Resource Include="Assets\llama-logo.png"/>
|
||||||
<Resource Include="Assets\llama-logo-icon.png"/>
|
<Resource Include="Assets\llama-logo-icon.png"/>
|
||||||
@@ -20,6 +27,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/>
|
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.10"/>
|
||||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/>
|
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.12"/>
|
||||||
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
Reference in New Issue
Block a user