From d662d9be4d9b7cca32769fc45e7a48dd9dee9700 Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Wed, 18 Feb 2026 02:24:10 -0600 Subject: [PATCH] Add a download single post/message option to the GUI --- OF DL.Gui/ViewModels/MainWindowViewModel.cs | 240 ++++++++++++++++++++ OF DL.Gui/Views/MainWindow.axaml | 93 ++++++++ OF DL.Gui/Views/MainWindow.axaml.cs | 3 +- docs/running-the-program.md | 13 ++ 4 files changed, 348 insertions(+), 1 deletion(-) diff --git a/OF DL.Gui/ViewModels/MainWindowViewModel.cs b/OF DL.Gui/ViewModels/MainWindowViewModel.cs index e51e985..af9b84d 100644 --- a/OF DL.Gui/ViewModels/MainWindowViewModel.cs +++ b/OF DL.Gui/ViewModels/MainWindowViewModel.cs @@ -27,7 +27,26 @@ public partial class MainWindowViewModel( IStartupService startupService, IDownloadOrchestrationService downloadOrchestrationService) : ViewModelBase { + private enum SingleDownloadType + { + Post, + PaidMessage + } + + private readonly record struct SingleDownloadRequest( + SingleDownloadType Type, + long ContentId, + string Username, + long? UserId); + private const string UnknownToolVersion = "Not detected"; + private static readonly Regex s_singlePostUrlRegex = new( + @"^https://onlyfans\.com/(?\d+)/(?[A-Za-z0-9_.-]+)/?$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex s_singlePaidMessageUrlRegex = new( + @"^https://onlyfans\.com/my/chats/chat/(?\d+)/?\?firstId=(?\d+)$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly string s_defaultDownloadPath = Path.GetFullPath( Path.Combine(Directory.GetCurrentDirectory(), "__user_data__", "sites", "OnlyFans")); @@ -297,6 +316,19 @@ public partial class MainWindowViewModel( [ObservableProperty] private string _downloadProgressDescription = string.Empty; + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(OpenSinglePostOrMessageModalCommand))] + [NotifyCanExecuteChangedFor(nameof(SubmitSinglePostOrMessageCommand))] + private bool _isSinglePostOrMessageModalOpen; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(SubmitSinglePostOrMessageCommand))] + private string _singlePostOrMessageUrl = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(HasSinglePostOrMessageUrlError))] + private string _singlePostOrMessageUrlError = string.Empty; + public bool IsLoadingScreen => CurrentScreen == AppScreen.Loading; public bool IsConfigScreen => CurrentScreen == AppScreen.Config; @@ -317,6 +349,8 @@ public partial class MainWindowViewModel( public bool HasMediaSourcesError => !string.IsNullOrWhiteSpace(MediaSourcesError); + public bool HasSinglePostOrMessageUrlError => !string.IsNullOrWhiteSpace(SinglePostOrMessageUrlError); + public string FfmpegPathDisplay => HidePrivateInfo && !string.IsNullOrWhiteSpace(FfmpegPath) ? "[Hidden for Privacy]" : FfmpegPath; @@ -688,6 +722,36 @@ public partial class MainWindowViewModel( [RelayCommand(CanExecute = nameof(CanDownloadPurchasedTab))] private async Task DownloadPurchasedTabAsync() => await RunDownloadAsync(true); + [RelayCommand(CanExecute = nameof(CanOpenSinglePostOrMessageModal))] + private void OpenSinglePostOrMessageModal() + { + SinglePostOrMessageUrl = string.Empty; + SinglePostOrMessageUrlError = string.Empty; + IsSinglePostOrMessageModalOpen = true; + } + + [RelayCommand(CanExecute = nameof(CanSubmitSinglePostOrMessage))] + private async Task SubmitSinglePostOrMessageAsync() + { + if (!TryParseSinglePostOrMessageUrl(SinglePostOrMessageUrl, out SingleDownloadRequest request, + out string validationError)) + { + SinglePostOrMessageUrlError = validationError; + return; + } + + IsSinglePostOrMessageModalOpen = false; + SinglePostOrMessageUrlError = string.Empty; + await RunSinglePostOrMessageDownloadAsync(request); + } + + [RelayCommand] + private void CancelSinglePostOrMessage() + { + SinglePostOrMessageUrlError = string.Empty; + IsSinglePostOrMessageModalOpen = false; + } + [RelayCommand(CanExecute = nameof(CanStopWork))] private void StopWork() { @@ -814,6 +878,16 @@ public partial class MainWindowViewModel( _allUsers.Count > 0 && !IsDownloading; + private bool CanOpenSinglePostOrMessageModal() => + CurrentScreen == AppScreen.UserSelection && + !IsDownloading && + !IsSinglePostOrMessageModalOpen; + + private bool CanSubmitSinglePostOrMessage() => + IsSinglePostOrMessageModalOpen && + !IsDownloading && + !string.IsNullOrWhiteSpace(SinglePostOrMessageUrl); + private bool CanStopWork() => IsDownloading; private bool CanRefreshUsers() => @@ -842,6 +916,8 @@ public partial class MainWindowViewModel( RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); LogoutCommand.NotifyCanExecuteChanged(); EditConfigCommand.NotifyCanExecuteChanged(); + OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged(); + SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged(); } partial void OnIsDownloadingChanged(bool value) @@ -853,6 +929,8 @@ public partial class MainWindowViewModel( RefreshUsersCommand.NotifyCanExecuteChanged(); RefreshIgnoredUsersListsCommand.NotifyCanExecuteChanged(); LogoutCommand.NotifyCanExecuteChanged(); + OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged(); + SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged(); } partial void OnIsAuthenticatedChanged(bool value) @@ -873,6 +951,11 @@ public partial class MainWindowViewModel( _ = SelectUsersFromListAsync(); } + partial void OnSinglePostOrMessageUrlChanged(string value) + { + SinglePostOrMessageUrlError = string.Empty; + } + partial void OnFfmpegPathChanged(string value) { if (value != "[Hidden for Privacy]") @@ -933,6 +1016,163 @@ public partial class MainWindowViewModel( await EnsureAuthenticationAndLoadUsersAsync(); } + private async Task RunSinglePostOrMessageDownloadAsync(SingleDownloadRequest request) + { + if (_allUsers.Count == 0) + { + StatusMessage = "No users are loaded. Refresh users and retry."; + return; + } + + IsDownloading = true; + _workCancellationSource?.Dispose(); + _workCancellationSource = new CancellationTokenSource(); + DownloadSelectedCommand.NotifyCanExecuteChanged(); + DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); + OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged(); + SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged(); + StopWorkCommand.NotifyCanExecuteChanged(); + + DateTime start = DateTime.Now; + string label = request.Type == SingleDownloadType.Post ? "single post" : "single paid message"; + AppendLog($"Starting {label} download from URL."); + StatusMessage = $"Starting {label} download..."; + + StartDownloadProgress($"Initializing {label} download...", 0, false); + + CancellationTokenSource cancellationSource = _workCancellationSource; + AvaloniaDownloadEventHandler eventHandler = new( + AppendLog, + UpdateProgressStatus, + StartDownloadProgress, + IncrementDownloadProgress, + StopDownloadProgress, + () => cancellationSource.IsCancellationRequested, + cancellationSource.Token); + + try + { + if (request.Type == SingleDownloadType.Post) + { + if (!_allUsers.TryGetValue(request.Username, out long userId)) + { + StatusMessage = + $"Creator '{request.Username}' is not in loaded users. Refresh users and ensure you are subscribed."; + AppendLog(StatusMessage); + return; + } + + string path = downloadOrchestrationService.ResolveDownloadPath(request.Username); + await downloadOrchestrationService.PrepareUserFolderAsync(request.Username, userId, path); + + await downloadOrchestrationService.DownloadSinglePostAsync( + request.Username, + request.ContentId, + path, + _allUsers, + _startupResult.ClientIdBlobMissing, + _startupResult.DevicePrivateKeyMissing, + eventHandler); + } + else + { + long userId = request.UserId ?? 0; + string? resolvedUsername = await downloadOrchestrationService.ResolveUsernameAsync(userId); + if (string.IsNullOrWhiteSpace(resolvedUsername)) + { + StatusMessage = $"Could not resolve username for user ID {userId}."; + AppendLog(StatusMessage); + return; + } + + Dictionary usersForDownload = new(_allUsers, StringComparer.Ordinal); + if (!usersForDownload.ContainsKey(resolvedUsername)) + { + usersForDownload[resolvedUsername] = userId; + } + + string path = downloadOrchestrationService.ResolveDownloadPath(resolvedUsername); + await downloadOrchestrationService.PrepareUserFolderAsync(resolvedUsername, userId, path); + + await downloadOrchestrationService.DownloadSinglePaidMessageAsync( + resolvedUsername, + request.ContentId, + path, + usersForDownload, + _startupResult.ClientIdBlobMissing, + _startupResult.DevicePrivateKeyMissing, + eventHandler); + } + + ThrowIfStopRequested(); + eventHandler.OnScrapeComplete(DateTime.Now - start); + StatusMessage = request.Type == SingleDownloadType.Post + ? "Single post download completed." + : "Single paid message download completed."; + } + catch (OperationCanceledException) + { + StatusMessage = "Operation canceled."; + AppendLog("Operation canceled."); + } + catch (Exception ex) + { + AppendLog($"Single item download failed: {ex.Message}"); + StatusMessage = "Single item download failed. Check logs."; + } + finally + { + IsDownloading = false; + _workCancellationSource?.Dispose(); + _workCancellationSource = null; + StopDownloadProgress(); + DownloadSelectedCommand.NotifyCanExecuteChanged(); + DownloadPurchasedTabCommand.NotifyCanExecuteChanged(); + OpenSinglePostOrMessageModalCommand.NotifyCanExecuteChanged(); + SubmitSinglePostOrMessageCommand.NotifyCanExecuteChanged(); + StopWorkCommand.NotifyCanExecuteChanged(); + } + } + + private static bool TryParseSinglePostOrMessageUrl( + string url, + out SingleDownloadRequest request, + out string validationError) + { + request = default; + validationError = string.Empty; + + string trimmedUrl = url.Trim(); + Match postMatch = s_singlePostUrlRegex.Match(trimmedUrl); + if (postMatch.Success && + long.TryParse(postMatch.Groups["postId"].Value, out long postId) && + !string.IsNullOrWhiteSpace(postMatch.Groups["username"].Value)) + { + request = new SingleDownloadRequest( + SingleDownloadType.Post, + postId, + postMatch.Groups["username"].Value, + null); + return true; + } + + Match paidMessageMatch = s_singlePaidMessageUrlRegex.Match(trimmedUrl); + if (paidMessageMatch.Success && + long.TryParse(paidMessageMatch.Groups["messageId"].Value, out long messageId) && + long.TryParse(paidMessageMatch.Groups["userId"].Value, out long userId)) + { + request = new SingleDownloadRequest( + SingleDownloadType.PaidMessage, + messageId, + string.Empty, + userId); + return true; + } + + validationError = "Please enter a valid post URL or paid message URL."; + return false; + } + private static void ApplyThemeFromConfigFileIfAvailable() { const string configPath = "config.conf"; diff --git a/OF DL.Gui/Views/MainWindow.axaml b/OF DL.Gui/Views/MainWindow.axaml index 7ebf7e1..1d269d5 100644 --- a/OF DL.Gui/Views/MainWindow.axaml +++ b/OF DL.Gui/Views/MainWindow.axaml @@ -1008,6 +1008,9 @@