Add a download single post/message option to the GUI

This commit is contained in:
whimsical-c4lic0 2026-02-18 02:24:10 -06:00
parent ac4061f1ca
commit d662d9be4d
4 changed files with 348 additions and 1 deletions

View File

@ -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/(?<postId>\d+)/(?<username>[A-Za-z0-9_.-]+)/?$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex s_singlePaidMessageUrlRegex = new(
@"^https://onlyfans\.com/my/chats/chat/(?<userId>\d+)/?\?firstId=(?<messageId>\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<string, long> 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";

View File

@ -1008,6 +1008,9 @@
<Button Content="Download Purchased Tab"
Classes="secondary"
Command="{Binding DownloadPurchasedTabCommand}" />
<Button Content="Download Single Post/Message"
Classes="secondary"
Command="{Binding OpenSinglePostOrMessageModalCommand}" />
</StackPanel>
<Button Grid.Column="1"
IsVisible="{Binding IsDownloading}"
@ -1349,5 +1352,95 @@
</ScrollViewer>
</Border>
</Grid>
<Grid Grid.Row="0" Grid.RowSpan="3"
IsVisible="{Binding IsSinglePostOrMessageModalOpen}"
Background="{DynamicResource OverlayBackgroundBrush}"
ZIndex="1001"
PointerPressed="OnModalOverlayClicked">
<Border Background="{DynamicResource ModalBackgroundBrush}"
BorderBrush="{DynamicResource ModalBorderBrush}"
BorderThickness="1"
CornerRadius="16"
Padding="32"
Width="760"
MaxHeight="700"
HorizontalAlignment="Center"
VerticalAlignment="Center"
BoxShadow="0 20 25 -5 #19000000, 0 10 10 -5 #0F000000"
PointerPressed="OnModalContentClicked">
<ScrollViewer>
<StackPanel Spacing="18" Margin="8,4,8,4">
<TextBlock FontSize="20"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="Download Single Post/Message" />
<TextBlock Foreground="{DynamicResource TextSecondaryBrush}"
TextWrapping="Wrap"
Text="Enter a post URL or paid message URL to download only that item." />
<Border Padding="16"
CornerRadius="10"
Background="{DynamicResource PreviewBackgroundBrush}"
BorderBrush="{DynamicResource PreviewBorderBrush}"
BorderThickness="1">
<StackPanel Spacing="14">
<TextBlock FontSize="15"
FontWeight="Bold"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="How to find a URL" />
<!-- Post Instructions -->
<StackPanel Spacing="6">
<TextBlock FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="Posts" />
<TextBlock Foreground="{DynamicResource TextSecondaryBrush}"
TextWrapping="Wrap"
Text="Click the ... menu on any post → 'Copy link to post'" />
</StackPanel>
<!-- Message Instructions -->
<StackPanel Spacing="6">
<TextBlock FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="Messages (Paid Only)" />
<TextBlock Foreground="{DynamicResource TextSecondaryBrush}"
TextWrapping="Wrap"
Text="Go to main timeline → Purchased tab → Find message → Click ... menu → 'Copy link to message'" />
<TextBlock Foreground="{DynamicResource TextSecondaryBrush}"
FontStyle="Italic"
FontSize="12"
TextWrapping="Wrap"
Text="Note: Only unlocked PPV messages can be downloaded individually. For other messages, download all messages from the creator." />
</StackPanel>
</StackPanel>
</Border>
<StackPanel Spacing="6">
<TextBlock FontWeight="SemiBold"
Foreground="{DynamicResource TextPrimaryBrush}"
Text="Post or Message URL" />
<TextBox Text="{Binding SinglePostOrMessageUrl}"
Watermark="https://onlyfans.com/... or https://onlyfans.com/my/chats/chat/.../?firstId=..." />
<TextBlock IsVisible="{Binding HasSinglePostOrMessageUrlError}"
Foreground="{DynamicResource ErrorTextBrush}"
Text="{Binding SinglePostOrMessageUrlError}"
TextWrapping="Wrap" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Content="Cancel"
Classes="secondary"
Command="{Binding CancelSinglePostOrMessageCommand}" />
<Button Content="Download"
Classes="primary"
Command="{Binding SubmitSinglePostOrMessageCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>
</Grid>
</Grid>
</Window>

View File

@ -183,8 +183,9 @@ public partial class MainWindow : Window
return;
}
// Execute cancel command on the modal
// Execute cancel command on any open modal
vm.CreatorConfigEditor.ModalViewModel?.CancelCommand?.Execute(null);
vm.CancelSinglePostOrMessageCommand.Execute(null);
}
private void OnModalContentClicked(object? sender, PointerPressedEventArgs e) =>

View File

@ -20,6 +20,19 @@ In GUI mode, the **Help** menu includes:
- **Documentation** (opens https://docs.ofdl.tools/)
- **About** (shows version details and project/license links)
In GUI mode, the main download screen includes:
- **Download Selected** (downloads configured content for selected creators)
- **Download Purchased Tab** (downloads purchased tab content)
- **Download Single Post/Message** (opens a modal to download one post or one paid message by URL)
For **Download Single Post/Message** in GUI:
- Post URL: click `...` on a post and choose **Copy link to post**.
- Message URL: only unlocked PPV paid messages are supported by URL. From the main timeline, open **Purchased**,
find the message, click `...`, and choose **Copy link to message**.
- Other message types cannot be downloaded individually by URL; scrape all messages for that creator instead.
If you're logged in successfully then you will be greeted with a selection prompt. To navigate the menu the can use the ↑ & ↓ arrows and press `enter` to choose that option.
![CLI main menu](/img/cli_menu.png)