forked from sim0n00ps/OF-DL
Add a download single post/message option to the GUI
This commit is contained in:
parent
ac4061f1ca
commit
d662d9be4d
@ -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";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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.
|
||||
|
||||

|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user