Add auth flow status updates to the GUI

This commit is contained in:
whimsical-c4lic0 2026-02-19 16:36:50 -06:00
parent 603c998ae9
commit 3b8e575a21
10 changed files with 114 additions and 60 deletions

View File

@ -73,19 +73,33 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
/// Launches a browser session and extracts auth data after login. /// Launches a browser session and extracts auth data after login.
/// </summary> /// </summary>
/// <returns>True when auth data is captured successfully.</returns> /// <returns>True when auth data is captured successfully.</returns>
public async Task<bool> LoadFromBrowserAsync() public async Task<bool> LoadFromBrowserAsync(Action<string>? statusCallback = null)
{ {
statusCallback?.Invoke("Preparing browser dependencies ...");
try try
{ {
bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null; bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null;
await SetupBrowser(runningInDocker); await SetupBrowser(runningInDocker);
}
catch (Exception ex)
{
statusCallback?.Invoke("Failed to prepare browser dependencies.");
Log.Error(ex, "Failed to download browser dependencies");
return false;
}
statusCallback?.Invoke("Please login using the opened Chromium window.");
try
{
CurrentAuth = await GetAuthFromBrowser(); CurrentAuth = await GetAuthFromBrowser();
return CurrentAuth != null; return CurrentAuth != null;
} }
catch (Exception ex) catch (Exception ex)
{ {
statusCallback?.Invoke("Failed to get auth from browser.");
Log.Error(ex, "Failed to load auth from browser"); Log.Error(ex, "Failed to load auth from browser");
return false; return false;
} }
@ -107,7 +121,7 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
{ {
string json = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented); string json = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented);
await File.WriteAllTextAsync(filePath, json); await File.WriteAllTextAsync(filePath, json);
Log.Debug($"Auth saved to file: {filePath}"); Log.Debug("Auth saved to file: {FilePath}", filePath);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -115,7 +129,7 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
} }
} }
private Task SetupBrowser(bool runningInDocker) private Task SetupBrowser(bool runningInDocker) => Task.Run(() =>
{ {
if (runningInDocker) if (runningInDocker)
{ {
@ -134,15 +148,16 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
if (folders.Any()) if (folders.Any())
{ {
Log.Information("chromium already downloaded. Skipping install step."); Log.Information("chromium already downloaded. Skipping install step.");
return Task.CompletedTask; return;
} }
} }
int exitCode = Program.Main(["install", "--with-deps", "chromium"]); int exitCode = Program.Main(["install", "--with-deps", "chromium"]);
return exitCode != 0 if (exitCode != 0)
? throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}") {
: Task.CompletedTask; throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}");
} }
});
private static async Task<string> GetBcToken(IPage page) => private static async Task<string> GetBcToken(IPage page) =>
await page.EvaluateAsync<string>("window.localStorage.getItem('bcTokenSha') || ''"); await page.EvaluateAsync<string>("window.localStorage.getItem('bcTokenSha') || ''");

View File

@ -1,6 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.Diagnostics;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using FFmpeg.NET; using FFmpeg.NET;

View File

@ -18,7 +18,10 @@ public interface IAuthService
/// <summary> /// <summary>
/// Launches a browser session and extracts auth data after login. /// Launches a browser session and extracts auth data after login.
/// </summary> /// </summary>
Task<bool> LoadFromBrowserAsync(); /// <param name="statusCallback">
/// Optional callback for reporting status messages to be displayed in the UI.
/// </param>
Task<bool> LoadFromBrowserAsync(Action<string>? statusCallback = null);
/// <summary> /// <summary>
/// Persists the current auth data to disk. /// Persists the current auth data to disk.

View File

@ -258,6 +258,9 @@ public partial class MainWindowViewModel(
[ObservableProperty] private string _authScreenMessage = string.Empty; [ObservableProperty] private string _authScreenMessage = string.Empty;
[ObservableProperty] [NotifyCanExecuteChangedFor(nameof(StartBrowserLoginCommand))]
private bool _isBrowserLoginInProgress;
[ObservableProperty] private string _errorMessage = string.Empty; [ObservableProperty] private string _errorMessage = string.Empty;
private string _actualFfmpegPath = string.Empty; private string _actualFfmpegPath = string.Empty;
@ -676,7 +679,7 @@ public partial class MainWindowViewModel(
CreatorConfigEditor.AddCreatorCommand.Execute(null); CreatorConfigEditor.AddCreatorCommand.Execute(null);
} }
[RelayCommand] [RelayCommand(CanExecute = nameof(CanStartBrowserLogin))]
private async Task StartBrowserLoginAsync() private async Task StartBrowserLoginAsync()
{ {
if (configService.CurrentConfig.DisableBrowserAuth) if (configService.CurrentConfig.DisableBrowserAuth)
@ -685,10 +688,29 @@ public partial class MainWindowViewModel(
return; return;
} }
SetLoading("Opening browser for authentication..."); IsBrowserLoginInProgress = true;
AppendLog("Starting browser authentication flow."); AppendLog("Starting browser authentication flow.");
bool success = await authService.LoadFromBrowserAsync(); StartBrowserLoginCommand.NotifyCanExecuteChanged();
bool success;
try
{
success = await authService.LoadFromBrowserAsync(message =>
{
if (string.IsNullOrWhiteSpace(message))
{
return;
}
AuthScreenMessage = message;
AppendLog(message);
});
}
finally
{
IsBrowserLoginInProgress = false;
}
if (!success || authService.CurrentAuth == null) if (!success || authService.CurrentAuth == null)
{ {
AuthScreenMessage = AuthScreenMessage =
@ -963,6 +985,11 @@ public partial class MainWindowViewModel(
CurrentScreen != AppScreen.Loading && CurrentScreen != AppScreen.Loading &&
!IsDownloading; !IsDownloading;
private bool CanStartBrowserLogin() =>
CurrentScreen == AppScreen.Auth &&
!IsDownloading &&
!IsBrowserLoginInProgress;
partial void OnCurrentScreenChanged(AppScreen value) partial void OnCurrentScreenChanged(AppScreen value)
{ {
OnPropertyChanged(nameof(IsLoadingScreen)); OnPropertyChanged(nameof(IsLoadingScreen));
@ -979,6 +1006,7 @@ public partial class MainWindowViewModel(
RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged();
LogoutCommand.NotifyCanExecuteChanged(); LogoutCommand.NotifyCanExecuteChanged();
EditConfigCommand.NotifyCanExecuteChanged(); EditConfigCommand.NotifyCanExecuteChanged();
StartBrowserLoginCommand.NotifyCanExecuteChanged();
OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged(); OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged();
SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged(); SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged();
} }
@ -992,6 +1020,7 @@ public partial class MainWindowViewModel(
RefreshUsersCommand.NotifyCanExecuteChanged(); RefreshUsersCommand.NotifyCanExecuteChanged();
RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged();
LogoutCommand.NotifyCanExecuteChanged(); LogoutCommand.NotifyCanExecuteChanged();
StartBrowserLoginCommand.NotifyCanExecuteChanged();
OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged(); OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged();
SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged(); SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged();
} }
@ -1425,7 +1454,8 @@ public partial class MainWindowViewModel(
private bool ValidateConfiguredToolPathsOnStartup() private bool ValidateConfiguredToolPathsOnStartup()
{ {
IReadOnlyDictionary<string, string> validationErrors = ConfigValidationService.Validate(configService.CurrentConfig); IReadOnlyDictionary<string, string> validationErrors =
ConfigValidationService.Validate(configService.CurrentConfig);
bool hasToolPathErrors = false; bool hasToolPathErrors = false;
FfmpegPathError = string.Empty; FfmpegPathError = string.Empty;
FfprobePathError = string.Empty; FfprobePathError = string.Empty;

View File

@ -46,8 +46,6 @@ public partial class AboutWindow : Window
private async void OnOpenFfprobeLicenseClick(object? sender, RoutedEventArgs e) => private async void OnOpenFfprobeLicenseClick(object? sender, RoutedEventArgs e) =>
await OpenExternalUrlAsync(FfprobeLicenseUrl); await OpenExternalUrlAsync(FfprobeLicenseUrl);
private void OnCloseClick(object? sender, RoutedEventArgs e) => Close();
private async Task OpenExternalUrlAsync(string url) private async Task OpenExternalUrlAsync(string url)
{ {
try try

View File

@ -61,10 +61,6 @@ public partial class FaqWindow : Window
public ObservableCollection<FaqEntry> Entries { get; } = []; public ObservableCollection<FaqEntry> Entries { get; } = [];
public string RuntimeSummary { get; private set; } = string.Empty;
private void OnCloseClick(object? sender, RoutedEventArgs e) => Close();
private void BuildEntries() private void BuildEntries()
{ {
Entries.Clear(); Entries.Clear();

View File

@ -994,6 +994,7 @@
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center"> <StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
<Button Content="Login with Browser" <Button Content="Login with Browser"
Classes="primary" Classes="primary"
IsEnabled="{Binding !IsBrowserLoginInProgress}"
Command="{Binding StartBrowserLoginCommand}" /> Command="{Binding StartBrowserLoginCommand}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>

View File

@ -515,7 +515,7 @@ public class ApiServiceTests
MethodInfo method = typeof(ApiService).GetMethod("TryGetDrmInfo", MethodInfo method = typeof(ApiService).GetMethod("TryGetDrmInfo",
BindingFlags.NonPublic | BindingFlags.Static) BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("TryGetDrmInfo not found."); ?? throw new InvalidOperationException("TryGetDrmInfo not found.");
object?[] args = { files, null, null, null, null }; object?[] args = [files, null, null, null, null];
bool result = (bool)method.Invoke(null, args)!; bool result = (bool)method.Invoke(null, args)!;
manifestDash = (string)args[1]!; manifestDash = (string)args[1]!;
cloudFrontPolicy = (string)args[2]!; cloudFrontPolicy = (string)args[2]!;

View File

@ -82,9 +82,10 @@ public class DownloadServiceTests
DownloadService service = DownloadService service =
CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService);
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo( (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result =
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", await service.GetDecryptionInfo(
true, false); "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
true, false);
Assert.NotNull(result); Assert.NotNull(result);
Assert.Equal("ofdl-key", result.Value.decryptionKey); Assert.Equal("ofdl-key", result.Value.decryptionKey);
@ -100,9 +101,10 @@ public class DownloadServiceTests
DownloadService service = DownloadService service =
CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService);
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo( (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result =
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", await service.GetDecryptionInfo(
false, false); "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
false, false);
Assert.NotNull(result); Assert.NotNull(result);
Assert.Equal("cdm-key", result.Value.decryptionKey); Assert.Equal("cdm-key", result.Value.decryptionKey);
@ -124,7 +126,8 @@ public class DownloadServiceTests
await File.WriteAllTextAsync(tempFilename, "abc"); await File.WriteAllTextAsync(tempFilename, "abc");
MediaTrackingDbService dbService = new(); MediaTrackingDbService dbService = new();
DownloadService service = CreateService(new FakeConfigService(new Config { ShowScrapeSize = false }), dbService); DownloadService service =
CreateService(new FakeConfigService(new Config { ShowScrapeSize = false }), dbService);
ProgressRecorder progress = new(); ProgressRecorder progress = new();
MethodInfo? finalizeMethod = typeof(DownloadService).GetMethod("FinalizeDrmDownload", MethodInfo? finalizeMethod = typeof(DownloadService).GetMethod("FinalizeDrmDownload",
@ -136,7 +139,7 @@ public class DownloadServiceTests
tempFilename, DateTime.UtcNow, folder, path, customFileName, filename, 1L, "Posts", progress tempFilename, DateTime.UtcNow, folder, path, customFileName, filename, 1L, "Posts", progress
]); ]);
bool result = await Assert.IsType<Task<bool>>(resultObject!); bool result = await Assert.IsType<Task<bool>>(resultObject);
Assert.True(result); Assert.True(result);
Assert.True(File.Exists(tempFilename)); Assert.True(File.Exists(tempFilename));
Assert.NotNull(dbService.LastUpdateMedia); Assert.NotNull(dbService.LastUpdateMedia);

View File

@ -145,36 +145,45 @@ internal sealed class StaticApiService : IApiService
public Task<List<string>?> GetListUsers(string endpoint) => throw new NotImplementedException(); public Task<List<string>?> GetListUsers(string endpoint) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Purchased.PaidPostCollection> GetPaidPosts(string endpoint, string folder, public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
string username, List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => string username, List<long> paidPostIds, IStatusReporter statusReporter,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Posts.PostCollection> GetPosts(string endpoint, string folder, public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException(); List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
public Task<OF_DL.Models.Entities.Posts.SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Streams.StreamsCollection> GetStreams(string endpoint, string folder, public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException(); CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Archived.ArchivedCollection> GetArchived(string endpoint, string folder, public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException(); List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Messages.MessageCollection> GetMessages(string endpoint, string folder, public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException(); IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Purchased.PaidMessageCollection> GetPaidMessages(string endpoint, public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
string folder, string username, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException(); IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Purchased.SinglePaidMessageCollection> GetPaidMessage(string endpoint, public Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint,
string folder, string username, IStatusReporter statusReporter,
CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint,
string folder, CancellationToken cancellationToken = default) => throw new NotImplementedException(); string folder, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, CancellationToken cancellationToken = default) => public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<List<OF_DL.Models.Entities.Purchased.PurchasedTabCollection>> GetPurchasedTab(string endpoint, public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint,
string folder, Dictionary<string, long> users, CancellationToken cancellationToken = default) => throw new NotImplementedException(); string folder, Dictionary<string, long> users, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default) => public Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
@ -222,10 +231,12 @@ internal sealed class ConfigurableApiService : IApiService
MediaHandler?.Invoke(mediaType, endpoint, username, folder) ?? MediaHandler?.Invoke(mediaType, endpoint, username, folder) ??
Task.FromResult<Dictionary<long, string>?>(null); Task.FromResult<Dictionary<long, string>?>(null);
public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default) => public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder,
CancellationToken cancellationToken = default) =>
PostHandler?.Invoke(endpoint, folder) ?? Task.FromResult(new PostEntities.SinglePostCollection()); PostHandler?.Invoke(endpoint, folder) ?? Task.FromResult(new PostEntities.SinglePostCollection());
public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder, CancellationToken cancellationToken = default) => public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder,
CancellationToken cancellationToken = default) =>
PaidMessageHandler?.Invoke(endpoint, folder) ?? PaidMessageHandler?.Invoke(endpoint, folder) ??
Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection()); Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection());
@ -259,7 +270,8 @@ internal sealed class ConfigurableApiService : IApiService
string username, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => string username, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users, CancellationToken cancellationToken = default) => public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder, public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
@ -295,7 +307,8 @@ internal sealed class OrchestrationDownloadServiceStub : IDownloadService
string serverFileName, string resolvedFileName, string extension, IProgressReporter progressReporter) => string serverFileName, string resolvedFileName, string extension, IProgressReporter progressReporter) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)?> GetDecryptionInfo(string mpdUrl, string policy, public Task<(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)?> GetDecryptionInfo(
string mpdUrl, string policy,
string signature, string kvp, string mediaId, string contentId, string drmType, bool clientIdBlobMissing, string signature, string kvp, string mediaId, string contentId, string drmType, bool clientIdBlobMissing,
bool devicePrivateKeyMissing) => bool devicePrivateKeyMissing) =>
throw new NotImplementedException(); throw new NotImplementedException();
@ -459,14 +472,9 @@ internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler
public void OnMessage(string message) => Messages.Add(message); public void OnMessage(string message) => Messages.Add(message);
} }
internal sealed class RecordingStatusReporter : IStatusReporter internal sealed class RecordingStatusReporter(string initialStatus) : IStatusReporter
{ {
private readonly List<string> _statuses; private readonly List<string> _statuses = [initialStatus];
public RecordingStatusReporter(string initialStatus)
{
_statuses = [initialStatus];
}
public IReadOnlyList<string> Statuses => _statuses; public IReadOnlyList<string> Statuses => _statuses;
@ -489,7 +497,8 @@ internal sealed class FakeAuthService : IAuthService
public Task<bool> LoadFromFileAsync(string filePath = "auth.json") => throw new NotImplementedException(); public Task<bool> LoadFromFileAsync(string filePath = "auth.json") => throw new NotImplementedException();
public Task<bool> LoadFromBrowserAsync() => throw new NotImplementedException(); public Task<bool> LoadFromBrowserAsync(Action<string>? dependencyStatusCallback = null) =>
throw new NotImplementedException();
public Task SaveToFileAsync(string filePath = "auth.json") => throw new NotImplementedException(); public Task SaveToFileAsync(string filePath = "auth.json") => throw new NotImplementedException();