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.
/// </summary>
/// <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
{
bool runningInDocker = Environment.GetEnvironmentVariable("OFDL_DOCKER") != null;
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();
return CurrentAuth != null;
}
catch (Exception ex)
{
statusCallback?.Invoke("Failed to get auth from browser.");
Log.Error(ex, "Failed to load auth from browser");
return false;
}
@ -107,7 +121,7 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
{
string json = JsonConvert.SerializeObject(CurrentAuth, Formatting.Indented);
await File.WriteAllTextAsync(filePath, json);
Log.Debug($"Auth saved to file: {filePath}");
Log.Debug("Auth saved to file: {FilePath}", filePath);
}
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)
{
@ -134,15 +148,16 @@ public class AuthService(IServiceProvider serviceProvider) : IAuthService
if (folders.Any())
{
Log.Information("chromium already downloaded. Skipping install step.");
return Task.CompletedTask;
return;
}
}
int exitCode = Program.Main(["install", "--with-deps", "chromium"]);
return exitCode != 0
? throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}")
: Task.CompletedTask;
}
if (exitCode != 0)
{
throw new Exception($"Playwright chromium download failed. Exited with code {exitCode}");
}
});
private static async Task<string> GetBcToken(IPage page) =>
await page.EvaluateAsync<string>("window.localStorage.getItem('bcTokenSha') || ''");

View File

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

View File

@ -18,7 +18,10 @@ public interface IAuthService
/// <summary>
/// Launches a browser session and extracts auth data after login.
/// </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>
/// Persists the current auth data to disk.

View File

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

View File

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

View File

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

View File

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

View File

@ -515,7 +515,7 @@ public class ApiServiceTests
MethodInfo method = typeof(ApiService).GetMethod("TryGetDrmInfo",
BindingFlags.NonPublic | BindingFlags.Static)
?? 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)!;
manifestDash = (string)args[1]!;
cloudFrontPolicy = (string)args[2]!;

View File

@ -82,9 +82,10 @@ public class DownloadServiceTests
DownloadService service =
CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService);
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo(
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
true, false);
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result =
await service.GetDecryptionInfo(
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
true, false);
Assert.NotNull(result);
Assert.Equal("ofdl-key", result.Value.decryptionKey);
@ -100,9 +101,10 @@ public class DownloadServiceTests
DownloadService service =
CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService);
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo(
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
false, false);
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result =
await service.GetDecryptionInfo(
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
false, false);
Assert.NotNull(result);
Assert.Equal("cdm-key", result.Value.decryptionKey);
@ -124,7 +126,8 @@ public class DownloadServiceTests
await File.WriteAllTextAsync(tempFilename, "abc");
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();
MethodInfo? finalizeMethod = typeof(DownloadService).GetMethod("FinalizeDrmDownload",
@ -136,7 +139,7 @@ public class DownloadServiceTests
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(File.Exists(tempFilename));
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<OF_DL.Models.Entities.Purchased.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
string username, List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder,
string username, List<long> paidPostIds, IStatusReporter statusReporter,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Posts.PostCollection> GetPosts(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Posts.SinglePostCollection> GetPost(string endpoint, string folder, CancellationToken cancellationToken = default) =>
public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Streams.StreamsCollection> GetStreams(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder,
CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Archived.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder,
List<long> paidPostIds, IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Messages.MessageCollection> GetMessages(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<OF_DL.Models.Entities.Purchased.PaidMessageCollection> GetPaidMessages(string endpoint,
string folder, string username, IStatusReporter statusReporter, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
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();
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();
public Task<List<OF_DL.Models.Entities.Purchased.PurchasedTabCollection>> GetPurchasedTab(string endpoint,
string folder, Dictionary<string, long> users, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint,
string folder, Dictionary<string, long> users, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
public Task<UserEntities.User?> GetUserInfo(string endpoint, CancellationToken cancellationToken = default) =>
throw new NotImplementedException();
@ -222,10 +231,12 @@ internal sealed class ConfigurableApiService : IApiService
MediaHandler?.Invoke(mediaType, endpoint, username, folder) ??
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());
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) ??
Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection());
@ -259,7 +270,8 @@ internal sealed class ConfigurableApiService : IApiService
string username, IStatusReporter statusReporter, 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();
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) =>
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,
bool devicePrivateKeyMissing) =>
throw new NotImplementedException();
@ -459,14 +472,9 @@ internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler
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;
public RecordingStatusReporter(string initialStatus)
{
_statuses = [initialStatus];
}
private readonly List<string> _statuses = [initialStatus];
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> 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();