From b4aac13bc6f21cfaffb349d81e8b6a29453a396b Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Fri, 13 Feb 2026 00:51:57 -0600 Subject: [PATCH 1/5] Compare downloaded DRM video durations against the duration reported by the MPD to ensure complete downloads --- .gitea/workflows/publish-release.yml | 5 +- OF DL.Core/Helpers/Constants.cs | 2 + OF DL.Core/Models/Config/Config.cs | 3 + OF DL.Core/Models/StartupResult.cs | 8 + OF DL.Core/Services/ApiService.cs | 108 ++--- OF DL.Core/Services/ConfigService.cs | 18 +- .../Services/DownloadOrchestrationService.cs | 2 +- OF DL.Core/Services/DownloadService.cs | 394 +++++++++++++----- OF DL.Core/Services/IApiService.cs | 10 +- OF DL.Core/Services/IDownloadService.cs | 2 +- OF DL.Core/Services/StartupService.cs | 98 ++++- OF DL.Tests/Services/ApiServiceTests.cs | 47 ++- OF DL.Tests/Services/ConfigServiceTests.cs | 22 + OF DL.Tests/Services/DownloadServiceTests.cs | 37 +- OF DL.Tests/Services/TestDoubles.cs | 17 +- OF DL/Program.cs | 54 ++- docs/config/all-configuration-options.md | 147 +++++-- docs/config/configuration.md | 2 + docs/installation/windows.md | 7 +- docs/running-the-program.md | 2 +- 20 files changed, 718 insertions(+), 267 deletions(-) diff --git a/.gitea/workflows/publish-release.yml b/.gitea/workflows/publish-release.yml index a11f22d..3b6ec39 100644 --- a/.gitea/workflows/publish-release.yml +++ b/.gitea/workflows/publish-release.yml @@ -52,12 +52,13 @@ jobs: echo "➤ Creating folder for CDM" mkdir -p cdm/devices/chrome_1610 - echo "➤ Copying ffmpeg from user folder" + echo "➤ Copying ffmpeg and ffprobe from user folder" cp /home/rhys/ffmpeg/ffmpeg-7.1.1-essentials_build/bin/ffmpeg.exe . + cp /home/rhys/ffmpeg/ffmpeg-7.1.1-essentials_build/bin/ffprobe.exe . cp /home/rhys/ffmpeg/ffmpeg-7.1.1-essentials_build/LICENSE LICENSE.ffmpeg echo "➤ Creating release zip" - zip ../OFDLV${{ steps.version.outputs.version }}.zip OF\ DL.exe e_sqlite3.dll rules.json config.conf cdm ffmpeg.exe LICENSE.ffmpeg + zip ../OFDLV${{ steps.version.outputs.version }}.zip OF\ DL.exe e_sqlite3.dll rules.json config.conf cdm ffmpeg.exe ffprobe.exe LICENSE.ffmpeg cd .. - name: Create release and upload artifact diff --git a/OF DL.Core/Helpers/Constants.cs b/OF DL.Core/Helpers/Constants.cs index 2843ee7..abe5439 100644 --- a/OF DL.Core/Helpers/Constants.cs +++ b/OF DL.Core/Helpers/Constants.cs @@ -9,4 +9,6 @@ public static class Constants public const int WidevineRetryDelay = 10; public const int WidevineMaxRetries = 3; + + public const int DrmDownloadMaxRetries = 3; } diff --git a/OF DL.Core/Models/Config/Config.cs b/OF DL.Core/Models/Config/Config.cs index c6d53fc..478a661 100644 --- a/OF DL.Core/Models/Config/Config.cs +++ b/OF DL.Core/Models/Config/Config.cs @@ -76,6 +76,7 @@ public class Config : IFileNameFormatConfig [ToggleableConfig] public bool NonInteractiveModePurchasedTab { get; set; } public string? FFmpegPath { get; set; } = ""; + public string? FFprobePath { get; set; } = ""; [ToggleableConfig] public bool BypassContentForCreatorsWhoNoLongerExist { get; set; } @@ -95,6 +96,8 @@ public class Config : IFileNameFormatConfig [JsonConverter(typeof(StringEnumConverter))] public VideoResolution DownloadVideoResolution { get; set; } = VideoResolution.source; + public double DrmVideoDurationMatchThreshold { get; set; } = 0.98; + // When enabled, post/message text is stored as-is without XML stripping. [ToggleableConfig] public bool DisableTextSanitization { get; set; } diff --git a/OF DL.Core/Models/StartupResult.cs b/OF DL.Core/Models/StartupResult.cs index 6b67cd0..9f187fa 100644 --- a/OF DL.Core/Models/StartupResult.cs +++ b/OF DL.Core/Models/StartupResult.cs @@ -14,6 +14,14 @@ public class StartupResult public string? FfmpegVersion { get; set; } + public bool FfprobeFound { get; set; } + + public bool FfprobePathAutoDetected { get; set; } + + public string? FfprobePath { get; set; } + + public string? FfprobeVersion { get; set; } + public bool ClientIdBlobMissing { get; set; } public bool DevicePrivateKeyMissing { get; set; } diff --git a/OF DL.Core/Services/ApiService.cs b/OF DL.Core/Services/ApiService.cs index c6126bd..163fc60 100644 --- a/OF DL.Core/Services/ApiService.cs +++ b/OF DL.Core/Services/ApiService.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text; +using System.Xml; using System.Xml.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; @@ -2575,19 +2576,26 @@ public class ApiService(IAuthService authService, IConfigService configService, /// - /// Retrieves the Widevine PSSH from an MPD manifest. + /// Retrieves DRM metadata (PSSH, Last-Modified, and duration) from an MPD manifest /// /// The MPD URL. /// CloudFront policy token. /// CloudFront signature token. /// CloudFront key pair ID. - /// The PSSH value or an empty string. - public async Task GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp) + /// Tuple with PSSH, Last-Modified, and duration seconds. + public async Task<(string pssh, DateTime lastModified, double? durationSeconds)> GetDrmMpdInfo( + string mpdUrl, string policy, string signature, string kvp) { + Log.Debug("Calling GetDrmMpdInfo"); + Log.Debug("mpdUrl: {MpdUrl}", mpdUrl); + Log.Debug("policy: {Policy}", policy); + Log.Debug("signature: {Signature}", signature); + Log.Debug("kvp: {Kvp}", kvp); + try { Auth? currentAuth = authService.CurrentAuth; - if (currentAuth == null || currentAuth.UserAgent == null || currentAuth.Cookie == null) + if (currentAuth?.UserAgent == null || currentAuth.Cookie == null) { throw new Exception("Auth service is missing required fields"); } @@ -2598,70 +2606,44 @@ public class ApiService(IAuthService authService, IConfigService configService, request.Headers.Add("Accept", "*/*"); request.Headers.Add("Cookie", $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {currentAuth.Cookie};"); + using HttpResponseMessage response = await client.SendAsync(request); response.EnsureSuccessStatusCode(); - string body = await response.Content.ReadAsStringAsync(); - XNamespace cenc = "urn:mpeg:cenc:2013"; - XDocument xmlDoc = XDocument.Parse(body); - IEnumerable psshElements = xmlDoc.Descendants(cenc + "pssh"); - string pssh = psshElements.ElementAt(1).Value; - return pssh; - } - catch (Exception ex) - { - ExceptionLoggerHelper.LogException(ex); - } - - return string.Empty; - } - - - /// - /// Retrieves the Last-Modified timestamp for an MPD manifest. - /// - /// The MPD URL. - /// CloudFront policy token. - /// CloudFront signature token. - /// CloudFront key pair ID. - /// The last modified timestamp. - public async Task GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp) - { - Log.Debug("Calling GetDrmMpdLastModified"); - Log.Debug($"mpdUrl: {mpdUrl}"); - Log.Debug($"policy: {policy}"); - Log.Debug($"signature: {signature}"); - Log.Debug($"kvp: {kvp}"); - - try - { - Auth? currentAuth = authService.CurrentAuth; - if (currentAuth == null || currentAuth.UserAgent == null || currentAuth.Cookie == null) + DateTime lastModified = response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now; + if (response.Content.Headers.LastModified == null + && response.Headers.TryGetValues("Last-Modified", out IEnumerable? lastModifiedValues)) { - throw new Exception("Auth service is missing required fields"); + string? lastModifiedRaw = lastModifiedValues.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(lastModifiedRaw) + && DateTimeOffset.TryParse(lastModifiedRaw, out DateTimeOffset parsedLastModified)) + { + lastModified = parsedLastModified.LocalDateTime; + } } - HttpClient client = new(); - HttpRequestMessage request = new(HttpMethod.Get, mpdUrl); - request.Headers.Add("user-agent", currentAuth.UserAgent); - request.Headers.Add("Accept", "*/*"); - request.Headers.Add("Cookie", - $"CloudFront-Policy={policy}; CloudFront-Signature={signature}; CloudFront-Key-Pair-Id={kvp}; {currentAuth.Cookie};"); - using HttpResponseMessage response = - await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - response.EnsureSuccessStatusCode(); - DateTime lastmodified = response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now; + string body = await response.Content.ReadAsStringAsync(); + XDocument xmlDoc = XDocument.Parse(body); - Log.Debug($"Last modified: {lastmodified}"); + XNamespace cenc = "urn:mpeg:cenc:2013"; + List psshElements = xmlDoc.Descendants(cenc + "pssh").ToList(); + string pssh = psshElements.Skip(1).FirstOrDefault()?.Value + ?? psshElements.FirstOrDefault()?.Value + ?? string.Empty; - return lastmodified; + string? durationText = xmlDoc.Root?.Attribute("mediaPresentationDuration")?.Value + ?? xmlDoc.Root?.Elements().FirstOrDefault(e => e.Name.LocalName == "Period") + ?.Attribute("duration")?.Value; + double? durationSeconds = ParseDurationSeconds(durationText); + + return (pssh, lastModified, durationSeconds); } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); } - return DateTime.Now; + return (string.Empty, DateTime.Now, null); } /// @@ -2809,6 +2791,24 @@ public class ApiService(IAuthService authService, IConfigService configService, return Task.FromResult(request); } + private static double? ParseDurationSeconds(string? iso8601Duration) + { + if (string.IsNullOrWhiteSpace(iso8601Duration)) + { + return null; + } + + try + { + TimeSpan duration = XmlConvert.ToTimeSpan(iso8601Duration); + return duration.TotalSeconds > 0 ? duration.TotalSeconds : null; + } + catch (FormatException) + { + return null; + } + } + private static double ConvertToUnixTimestampWithMicrosecondPrecision(DateTime date) { DateTime origin = new(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); diff --git a/OF DL.Core/Services/ConfigService.cs b/OF DL.Core/Services/ConfigService.cs index f71586b..c859d4a 100644 --- a/OF DL.Core/Services/ConfigService.cs +++ b/OF DL.Core/Services/ConfigService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Reflection; using System.Text; using Akka.Configuration; @@ -164,6 +165,7 @@ public class ConfigService(ILoggingService loggingService) : IConfigService // FFmpeg Settings FFmpegPath = hoconConfig.GetString("External.FFmpegPath"), + FFprobePath = hoconConfig.GetString("External.FFprobePath", ""), // Download Settings DownloadAvatarHeaderPhoto = hoconConfig.GetBoolean("Download.Media.DownloadAvatarHeaderPhoto"), @@ -194,11 +196,13 @@ public class ConfigService(ILoggingService loggingService) : IConfigService : null, ShowScrapeSize = hoconConfig.GetBoolean("Download.ShowScrapeSize"), DisableTextSanitization = - bool.TryParse(hoconConfig.GetString("Download.DisableTextSanitization", "false"), out bool dts) - ? dts - : false, + bool.TryParse(hoconConfig.GetString("Download.DisableTextSanitization", "false"), out bool dts) && + dts, DownloadVideoResolution = ParseVideoResolution(hoconConfig.GetString("Download.DownloadVideoResolution", "source")), + DrmVideoDurationMatchThreshold = + ParseDrmVideoDurationMatchThreshold( + hoconConfig.GetString("Download.DrmVideoDurationMatchThreshold", "0.98")), // File Settings PaidPostFileNameFormat = hoconConfig.GetString("File.PaidPostFileNameFormat"), @@ -311,6 +315,7 @@ public class ConfigService(ILoggingService loggingService) : IConfigService hocon.AppendLine("# External Tools"); hocon.AppendLine("External {"); hocon.AppendLine($" FFmpegPath = \"{config.FFmpegPath}\""); + hocon.AppendLine($" FFprobePath = \"{config.FFprobePath}\""); hocon.AppendLine("}"); hocon.AppendLine("# Download Settings"); @@ -343,6 +348,8 @@ public class ConfigService(ILoggingService loggingService) : IConfigService hocon.AppendLine($" DisableTextSanitization = {config.DisableTextSanitization.ToString().ToLower()}"); hocon.AppendLine( $" DownloadVideoResolution = \"{(config.DownloadVideoResolution == VideoResolution.source ? "source" : config.DownloadVideoResolution.ToString().TrimStart('_'))}\""); + hocon.AppendLine( + $" DrmVideoDurationMatchThreshold = {config.DrmVideoDurationMatchThreshold.ToString(CultureInfo.InvariantCulture)}"); hocon.AppendLine("}"); hocon.AppendLine("# File Settings"); @@ -492,4 +499,9 @@ public class ConfigService(ILoggingService loggingService) : IConfigService return Enum.Parse("_" + value, true); } + + private static double ParseDrmVideoDurationMatchThreshold(string value) => + !double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) + ? 0.98 + : Math.Clamp(parsed, 0.01, 1.0); } diff --git a/OF DL.Core/Services/DownloadOrchestrationService.cs b/OF DL.Core/Services/DownloadOrchestrationService.cs index 2a27c79..2c4b9c5 100644 --- a/OF DL.Core/Services/DownloadOrchestrationService.cs +++ b/OF DL.Core/Services/DownloadOrchestrationService.cs @@ -192,7 +192,7 @@ public class DownloadOrchestrationService( { eventHandler.OnMessage( "Getting Posts (this may take a long time, depending on the number of Posts the creator has)"); - Log.Debug($"Calling DownloadFreePosts - {username}"); + Log.Debug("Calling DownloadFreePosts - {Username}", username); counts.PostCount = await DownloadContentTypeAsync("Posts", async statusReporter => diff --git a/OF DL.Core/Services/DownloadService.cs b/OF DL.Core/Services/DownloadService.cs index 5fedd5a..235c287 100644 --- a/OF DL.Core/Services/DownloadService.cs +++ b/OF DL.Core/Services/DownloadService.cs @@ -1,3 +1,5 @@ +using System.Diagnostics; +using System.Globalization; using System.Security.Cryptography; using System.Text.RegularExpressions; using FFmpeg.NET; @@ -111,14 +113,26 @@ public class DownloadService( } } - private async Task DownloadDrmMedia(string userAgent, string policy, string signature, string kvp, - string sess, string url, string decryptionKey, string folder, DateTime lastModified, long mediaId, - string apiType, IProgressReporter progressReporter, string customFileName, string filename, string path) + private async Task DownloadDrmMedia( + string userAgent, + string policy, + string signature, + string kvp, + string sess, + string url, + string decryptionKey, + string folder, + DateTime lastModified, + long mediaId, + string apiType, + IProgressReporter progressReporter, + string customFileName, + string filename, + string path, + double? expectedDurationSeconds) { try { - _completionSource = new TaskCompletionSource(); - int pos1 = decryptionKey.IndexOf(':'); string decKey = ""; if (pos1 >= 0) @@ -126,25 +140,20 @@ public class DownloadService( decKey = decryptionKey[(pos1 + 1)..]; } - int streamIndex = 0; + const int streamIndex = 0; string tempFilename = $"{folder}{path}/{filename}_source.mp4"; + string finalFilePath = !string.IsNullOrEmpty(customFileName) + ? $"{folder}{path}/{customFileName}.mp4" + : tempFilename; - // Configure ffmpeg log level and optional report file location - bool ffmpegDebugLogging = Log.IsEnabled(LogEventLevel.Debug); - - string logLevelArgs = ffmpegDebugLogging || - configService.CurrentConfig.LoggingLevel is LoggingLevel.Verbose or LoggingLevel.Debug + // Configure ffmpeg log level and optional report file location. + string ffToolLogLevel = GetFfToolLogLevel(); + bool enableFfReport = string.Equals(ffToolLogLevel, "debug", StringComparison.OrdinalIgnoreCase); + string logLevelArgs = enableFfReport ? "-loglevel debug -report" - : configService.CurrentConfig.LoggingLevel switch - { - LoggingLevel.Information => "-loglevel info", - LoggingLevel.Warning => "-loglevel warning", - LoggingLevel.Error => "-loglevel error", - LoggingLevel.Fatal => "-loglevel fatal", - _ => "" - }; + : $"-loglevel {ffToolLogLevel}"; - if (logLevelArgs.Contains("-report", StringComparison.OrdinalIgnoreCase)) + if (enableFfReport) { // Use a relative path so FFREPORT parsing works on Windows (drive-letter ':' breaks option parsing). string logDir = Path.Combine(Environment.CurrentDirectory, "logs"); @@ -167,70 +176,249 @@ public class DownloadService( $"CloudFront-Key-Pair-Id={kvp}; " + $"{sess}"; - string parameters = - $"{logLevelArgs} " + - $"-cenc_decryption_key {decKey} " + - $"-headers \"{cookieHeader}\" " + - $"-user_agent \"{userAgent}\" " + - "-referer \"https://onlyfans.com\" " + - "-rw_timeout 20000000 " + - "-reconnect 1 -reconnect_streamed 1 -reconnect_on_network_error 1 -reconnect_delay_max 10 " + - "-y " + - $"-i \"{url}\" " + - $"-map 0:v:{streamIndex} -map 0:a? " + - "-c copy " + - $"\"{tempFilename}\""; - - Log.Debug($"Calling FFMPEG with Parameters: {parameters}"); - - Engine ffmpeg = new(configService.CurrentConfig.FFmpegPath); - ffmpeg.Error += OnError; - ffmpeg.Complete += async (_, _) => + if (expectedDurationSeconds.HasValue) { - _completionSource.TrySetResult(true); - await OnFFMPEGDownloadComplete(tempFilename, lastModified, folder, path, customFileName, filename, - mediaId, apiType, progressReporter); - }; - await ffmpeg.ExecuteAsync(parameters, CancellationToken.None); + Log.Debug("Expected DRM video duration for media {MediaId}: {ExpectedDurationSeconds:F2}s", mediaId, + expectedDurationSeconds.Value); + } + else + { + Log.Warning("MPD video duration missing for media {MediaId}; skipping DRM duration validation.", + mediaId); + } - return await _completionSource.Task; + double threshold = configService.CurrentConfig.DrmVideoDurationMatchThreshold; + for (int attempt = 1; attempt <= Constants.DrmDownloadMaxRetries; attempt++) + { + TryDeleteFile(tempFilename); + if (!string.Equals(finalFilePath, tempFilename, StringComparison.OrdinalIgnoreCase)) + { + TryDeleteFile(finalFilePath); + } + + _completionSource = new TaskCompletionSource(); + + string parameters = + $"{logLevelArgs} " + + $"-cenc_decryption_key {decKey} " + + $"-headers \"{cookieHeader}\" " + + $"-user_agent \"{userAgent}\" " + + "-referer \"https://onlyfans.com\" " + + "-rw_timeout 20000000 " + + "-reconnect 1 -reconnect_streamed 1 -reconnect_on_network_error 1 -reconnect_delay_max 10 " + + "-y " + + $"-i \"{url}\" " + + $"-map 0:v:{streamIndex} -map 0:a? " + + "-c copy " + + $"\"{tempFilename}\""; + + Log.Debug("Calling FFmpeg with Parameters: {Parameters}", parameters); + + Engine ffmpeg = new(configService.CurrentConfig.FFmpegPath); + ffmpeg.Error += OnError; + ffmpeg.Complete += (_, _) => { _completionSource.TrySetResult(true); }; + await ffmpeg.ExecuteAsync(parameters, CancellationToken.None); + + bool ffmpegSuccess = await _completionSource.Task; + if (!ffmpegSuccess || !File.Exists(tempFilename)) + { + Log.Warning("DRM download attempt {Attempt}/{MaxAttempts} failed for media {MediaId}.", attempt, + Constants.DrmDownloadMaxRetries, mediaId); + continue; + } + + if (!expectedDurationSeconds.HasValue) + { + return await FinalizeDrmDownload(tempFilename, lastModified, folder, path, customFileName, filename, + mediaId, apiType, progressReporter); + } + + double? actualDurationSeconds = await TryGetVideoDurationSeconds(tempFilename); + if (!actualDurationSeconds.HasValue) + { + Log.Warning( + "DRM download attempt {Attempt}/{MaxAttempts} could not determine output duration for media {MediaId}.", + attempt, Constants.DrmDownloadMaxRetries, mediaId); + continue; + } + + double durationRatio = actualDurationSeconds.Value / expectedDurationSeconds.Value; + Log.Debug("Expected duration: {ExpectedSeconds:F2}s Actual duration: {ActualSeconds:F2}s", + expectedDurationSeconds.Value, actualDurationSeconds.Value); + Log.Debug("Ratio: {Ratio:P2} Threshold: {Threshold:P2}", durationRatio, threshold); + if (durationRatio >= threshold) + { + return await FinalizeDrmDownload(tempFilename, lastModified, folder, path, customFileName, filename, + mediaId, apiType, progressReporter); + } + + Log.Warning( + "DRM download attempt {Attempt}/{MaxAttempts} produced a short file for media {MediaId}. Expected={ExpectedSeconds:F2}s Actual={ActualSeconds:F2}s Ratio={Ratio:P2} Threshold={Threshold:P2}.", + attempt, Constants.DrmDownloadMaxRetries, mediaId, expectedDurationSeconds.Value, + actualDurationSeconds.Value, durationRatio, threshold); + } + + TryDeleteFile(tempFilename); + Log.Warning("DRM download failed validation after {MaxAttempts} attempts for media {MediaId}.", + Constants.DrmDownloadMaxRetries, mediaId); + return false; } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); + return false; } - - return false; } - private async Task OnFFMPEGDownloadComplete(string tempFilename, DateTime lastModified, string folder, string path, + private async Task TryGetVideoDurationSeconds(string filePath) + { + try + { + string ffprobePath = configService.CurrentConfig.FFprobePath + ?? throw new InvalidOperationException("FFprobePath is not configured."); + string ffprobeLogLevel = GetFfToolLogLevel(); + ProcessStartInfo startInfo = new() + { + FileName = ffprobePath, + Arguments = + $"-v {ffprobeLogLevel} -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"{filePath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using Process process = new(); + process.StartInfo = startInfo; + process.Start(); + + Task outputTask = process.StandardOutput.ReadToEndAsync(); + Task errorTask = process.StandardError.ReadToEndAsync(); + Task waitForExitTask = process.WaitForExitAsync(); + await Task.WhenAll(outputTask, errorTask, waitForExitTask); + + string output = outputTask.Result; + string error = errorTask.Result; + + if (process.ExitCode != 0) + { + Log.Warning("FFprobe failed for file {FilePath}. ExitCode={ExitCode} Error={Error}", filePath, + process.ExitCode, error); + return null; + } + + string[] outputLines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries); + foreach (string outputLine in outputLines) + { + if (double.TryParse(outputLine.Trim(), NumberStyles.Float, CultureInfo.InvariantCulture, + out double durationSeconds)) + { + return durationSeconds > 0 ? durationSeconds : null; + } + } + + Log.Warning("Unable to parse FFprobe duration for file {FilePath}. RawOutput={RawOutput}", filePath, + output.Trim()); + return null; + } + catch (Exception ex) + { + Log.Warning(ex, "Failed to inspect downloaded video duration for {FilePath}", filePath); + return null; + } + } + + private string GetFfToolLogLevel() + { + bool ffToolDebugLogging = Log.IsEnabled(LogEventLevel.Debug); + if (ffToolDebugLogging || + configService.CurrentConfig.LoggingLevel is LoggingLevel.Verbose or LoggingLevel.Debug) + { + return "debug"; + } + + return configService.CurrentConfig.LoggingLevel switch + { + LoggingLevel.Information => "info", + LoggingLevel.Warning => "warning", + LoggingLevel.Error => "error", + LoggingLevel.Fatal => "fatal", + _ => "error" + }; + } + + private async Task FinalizeDrmDownload(string tempFilename, DateTime lastModified, string folder, string path, string customFileName, string filename, long mediaId, string apiType, IProgressReporter progressReporter) { try { - if (File.Exists(tempFilename)) + if (!File.Exists(tempFilename)) { - File.SetLastWriteTime(tempFilename, lastModified); + return false; } + File.SetLastWriteTime(tempFilename, lastModified); + + string finalPath = tempFilename; + string finalName = filename + "_source.mp4"; if (!string.IsNullOrEmpty(customFileName)) { - File.Move(tempFilename, $"{folder + path + "/" + customFileName + ".mp4"}"); + finalPath = $"{folder}{path}/{customFileName}.mp4"; + finalName = customFileName + ".mp4"; + + if (!AreSamePath(tempFilename, finalPath)) + { + TryDeleteFile(finalPath); + File.Move(tempFilename, finalPath); + } } - // Cleanup Files - long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) - ? folder + path + "/" + customFileName + ".mp4" - : tempFilename).Length; + long fileSizeInBytes = new FileInfo(finalPath).Length; ReportProgress(progressReporter, fileSizeInBytes); - await dbService.UpdateMedia(folder, mediaId, apiType, folder + path, - !string.IsNullOrEmpty(customFileName) ? customFileName + ".mp4" : filename + "_source.mp4", - fileSizeInBytes, true, lastModified); + await dbService.UpdateMedia(folder, mediaId, apiType, folder + path, finalName, fileSizeInBytes, true, + lastModified); + return true; } catch (Exception ex) { ExceptionLoggerHelper.LogException(ex); + return false; + } + } + + private static void TryDeleteFile(string filePath) + { + try + { + if (File.Exists(filePath)) + { + File.Delete(filePath); + } + } + catch + { + // Best-effort cleanup only. + } + } + + private static bool AreSamePath(string firstPath, string secondPath) + { + try + { + string firstFullPath = Path.GetFullPath(firstPath); + string secondFullPath = Path.GetFullPath(secondPath); + StringComparison comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals(firstFullPath, secondFullPath, comparison); + } + catch + { + StringComparison comparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + return string.Equals(firstPath, secondPath, comparison); } } @@ -869,12 +1057,13 @@ public class DownloadService( /// Media info. /// Author info. /// Known users map. + /// The expected duration of the video in seconds. /// True when the media is newly downloaded. private async Task DownloadDrmVideo(string policy, string signature, string kvp, string url, string decryptionKey, string folder, DateTime lastModified, long mediaId, string apiType, IProgressReporter progressReporter, string path, string? filenameFormat, object? postInfo, object? postMedia, - object? author, Dictionary users) + object? author, Dictionary users, double? expectedDurationSeconds) { try { @@ -912,7 +1101,7 @@ public class DownloadService( { return await DownloadDrmMedia(authService.CurrentAuth.UserAgent, policy, signature, kvp, authService.CurrentAuth.Cookie, url, decryptionKey, folder, lastModified, mediaId, apiType, - progressReporter, customFileName, filename, path); + progressReporter, customFileName, filename, path, expectedDurationSeconds); } long fileSizeInBytes = new FileInfo(!string.IsNullOrEmpty(customFileName) @@ -985,15 +1174,14 @@ public class DownloadService( /// The DRM type. /// Whether the CDM client ID blob is missing. /// Whether the CDM private key is missing. - /// The decryption key and last modified timestamp. - public async Task<(string decryptionKey, DateTime lastModified)?> GetDecryptionInfo( + /// The decryption key, last modified timestamp, and MPD duration seconds. + public async 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) { - string pssh = await apiService.GetDrmMpdPssh(mpdUrl, policy, signature, kvp); - - DateTime lastModified = await apiService.GetDrmMpdLastModified(mpdUrl, policy, signature, kvp); + (string pssh, DateTime lastModified, double? durationSeconds) = + await apiService.GetDrmMpdInfo(mpdUrl, policy, signature, kvp); Dictionary drmHeaders = apiService.GetDynamicHeaders($"/api2/v2/users/media/{mediaId}/drm/{drmType}/{contentId}", "?type=widevine"); @@ -1004,7 +1192,7 @@ public class DownloadService( ? await apiService.GetDecryptionKeyOfdl(drmHeaders, licenseUrl, pssh) : await apiService.GetDecryptionKeyCdm(drmHeaders, licenseUrl, pssh); - return (decryptionKey, lastModified); + return (decryptionKey, lastModified, durationSeconds); } /// @@ -1182,9 +1370,10 @@ public class DownloadService( if (archivedKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = archivedKvp.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], - parsed[2], parsed[3], - parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) { continue; @@ -1193,7 +1382,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, archivedKvp.Key, "Posts", progressReporter, "/Archived/Posts/Free/Videos", filenameFormat, - postInfo, mediaInfo, postInfo?.Author, users); + postInfo, mediaInfo, postInfo?.Author, users, drmInfo.Value.mpdDurationSeconds); } else { @@ -1276,9 +1465,10 @@ public class DownloadService( if (messageKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = messageKvp.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], - parsed[2], parsed[3], - parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) { continue; @@ -1287,7 +1477,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, messageKvp.Key, "Messages", progressReporter, messagePath + "/Videos", filenameFormat, - messageInfo, mediaInfo, messageInfo?.FromUser, users); + messageInfo, mediaInfo, messageInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds); } else { @@ -1372,9 +1562,10 @@ public class DownloadService( if (kvpEntry.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = kvpEntry.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], - parsed[2], parsed[3], - parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) { continue; @@ -1383,7 +1574,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, kvpEntry.Key, "Messages", progressReporter, paidMsgPath + "/Videos", filenameFormat, - messageInfo, mediaInfo, messageInfo?.FromUser, users); + messageInfo, mediaInfo, messageInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds); } else { @@ -1464,9 +1655,10 @@ public class DownloadService( if (kvpEntry.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = kvpEntry.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], - parsed[2], parsed[3], - parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) { continue; @@ -1475,7 +1667,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, kvpEntry.Key, "Streams", progressReporter, streamPath + "/Videos", filenameFormat, - streamInfo, mediaInfo, streamInfo?.Author, users); + streamInfo, mediaInfo, streamInfo?.Author, users, drmInfo.Value.mpdDurationSeconds); } else { @@ -1556,9 +1748,10 @@ public class DownloadService( if (postKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = postKvp.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], - parsed[2], parsed[3], - parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) { continue; @@ -1567,7 +1760,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, postKvp.Key, "Posts", progressReporter, postPath + "/Videos", filenameFormat, - postInfo, mediaInfo, postInfo?.Author, users); + postInfo, mediaInfo, postInfo?.Author, users, drmInfo.Value.mpdDurationSeconds); } else { @@ -1649,9 +1842,10 @@ public class DownloadService( if (postKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = postKvp.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], - parsed[2], parsed[3], - parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = + await GetDecryptionInfo(parsed[0], parsed[1], + parsed[2], parsed[3], + parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) { continue; @@ -1660,7 +1854,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, postKvp.Key, "Posts", progressReporter, paidPostPath + "/Videos", filenameFormat, - postInfo, mediaInfo, postInfo?.FromUser, users); + postInfo, mediaInfo, postInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds); } else { @@ -1733,7 +1927,7 @@ public class DownloadService( if (purchasedPostKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = purchasedPostKvp.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3], parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) @@ -1744,7 +1938,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, purchasedPostKvp.Key, "Posts", progressReporter, paidPostPath + "/Videos", filenameFormat, - postInfo, mediaInfo, postInfo?.FromUser, users); + postInfo, mediaInfo, postInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds); } else { @@ -1817,7 +2011,7 @@ public class DownloadService( if (paidMessageKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = paidMessageKvp.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3], parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) @@ -1828,7 +2022,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, paidMessageKvp.Key, "Messages", progressReporter, paidMsgPath + "/Videos", filenameFormat, - messageInfo, mediaInfo, messageInfo?.FromUser, users); + messageInfo, mediaInfo, messageInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds); } else { @@ -1899,7 +2093,7 @@ public class DownloadService( if (postKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = postKvp.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3], parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) @@ -1910,7 +2104,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, postKvp.Key, "Posts", progressReporter, postPath + "/Videos", filenameFormat, - postInfo, mediaInfo, postInfo?.Author, users); + postInfo, mediaInfo, postInfo?.Author, users, drmInfo.Value.mpdDurationSeconds); } else { @@ -1989,7 +2183,7 @@ public class DownloadService( if (paidMessageKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = paidMessageKvp.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3], parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) @@ -2000,7 +2194,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, paidMessageKvp.Key, "Messages", progressReporter, previewMsgPath + "/Videos", filenameFormat, - messageInfo, mediaInfo, messageInfo?.FromUser, users); + messageInfo, mediaInfo, messageInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds); } else { @@ -2043,7 +2237,7 @@ public class DownloadService( if (paidMessageKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { string[] parsed = paidMessageKvp.Value.Split(','); - (string decryptionKey, DateTime lastModified)? drmInfo = + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3], parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing); if (drmInfo == null) @@ -2054,7 +2248,7 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, paidMessageKvp.Key, "Messages", progressReporter, singlePaidMsgPath + "/Videos", filenameFormat, - messageInfo, mediaInfo, messageInfo?.FromUser, users); + messageInfo, mediaInfo, messageInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds); } else { diff --git a/OF DL.Core/Services/IApiService.cs b/OF DL.Core/Services/IApiService.cs index 1a293bc..e11b379 100644 --- a/OF DL.Core/Services/IApiService.cs +++ b/OF DL.Core/Services/IApiService.cs @@ -17,14 +17,10 @@ public interface IApiService Task GetDecryptionKeyCdm(Dictionary drmHeaders, string licenceUrl, string pssh); /// - /// Retrieves the last modified timestamp for a DRM MPD manifest. + /// Retrieves DRM MPD metadata from a single request. /// - Task GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp); - - /// - /// Retrieves the Widevine PSSH from an MPD manifest. - /// - Task GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp); + Task<(string pssh, DateTime lastModified, double? durationSeconds)> GetDrmMpdInfo( + string mpdUrl, string policy, string signature, string kvp); /// /// Retrieves the user's lists. diff --git a/OF DL.Core/Services/IDownloadService.cs b/OF DL.Core/Services/IDownloadService.cs index 598b09b..9e61714 100644 --- a/OF DL.Core/Services/IDownloadService.cs +++ b/OF DL.Core/Services/IDownloadService.cs @@ -23,7 +23,7 @@ public interface IDownloadService /// /// Retrieves decryption information for a DRM media item. /// - Task<(string decryptionKey, DateTime lastModified)?> GetDecryptionInfo( + 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); diff --git a/OF DL.Core/Services/StartupService.cs b/OF DL.Core/Services/StartupService.cs index c5e4570..04f8c46 100644 --- a/OF DL.Core/Services/StartupService.cs +++ b/OF DL.Core/Services/StartupService.cs @@ -34,8 +34,10 @@ public class StartupService(IConfigService configService, IAuthService authServi // FFmpeg detection DetectFfmpeg(result); + // FFprobe detection + DetectFfprobe(result); - if (result.FfmpegFound && result.FfmpegPath != null) + if (result is { FfmpegFound: true, FfmpegPath: not null }) { // Escape backslashes for Windows if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && @@ -47,7 +49,22 @@ public class StartupService(IConfigService configService, IAuthService authServi } // Get FFmpeg version - result.FfmpegVersion = await GetFfmpegVersionAsync(result.FfmpegPath); + result.FfmpegVersion = await GetToolVersionAsync(result.FfmpegPath, "ffmpeg"); + } + + if (result is { FfprobeFound: true, FfprobePath: not null }) + { + // Escape backslashes for Windows + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && + result.FfprobePath.Contains(@":\") && + !result.FfprobePath.Contains(@":\\")) + { + result.FfprobePath = result.FfprobePath.Replace(@"\", @"\\"); + configService.CurrentConfig.FFprobePath = result.FfprobePath; + } + + // Get FFprobe version + result.FfprobeVersion = await GetToolVersionAsync(result.FfprobePath, "ffprobe"); } // Widevine device checks @@ -146,8 +163,8 @@ public class StartupService(IConfigService configService, IAuthService authServi { result.FfmpegFound = true; result.FfmpegPath = configService.CurrentConfig.FFmpegPath; - Log.Debug($"FFMPEG found: {result.FfmpegPath}"); - Log.Debug("FFMPEG path set in config.conf"); + Log.Debug($"FFmpeg found: {result.FfmpegPath}"); + Log.Debug("FFmpeg path set in config.conf"); } else if (!string.IsNullOrEmpty(authService.CurrentAuth?.FfmpegPath) && ValidateFilePath(authService.CurrentAuth.FfmpegPath)) @@ -155,8 +172,8 @@ public class StartupService(IConfigService configService, IAuthService authServi result.FfmpegFound = true; result.FfmpegPath = authService.CurrentAuth.FfmpegPath; configService.CurrentConfig.FFmpegPath = result.FfmpegPath; - Log.Debug($"FFMPEG found: {result.FfmpegPath}"); - Log.Debug("FFMPEG path set in auth.json"); + Log.Debug($"FFmpeg found: {result.FfmpegPath}"); + Log.Debug("FFmpeg path set in auth.json"); } else if (string.IsNullOrEmpty(configService.CurrentConfig.FFmpegPath)) { @@ -167,8 +184,8 @@ public class StartupService(IConfigService configService, IAuthService authServi result.FfmpegPathAutoDetected = true; result.FfmpegPath = ffmpegPath; configService.CurrentConfig.FFmpegPath = ffmpegPath; - Log.Debug($"FFMPEG found: {ffmpegPath}"); - Log.Debug("FFMPEG path found via PATH or current directory"); + Log.Debug($"FFmpeg found: {ffmpegPath}"); + Log.Debug("FFmpeg path found via PATH or current directory"); } } @@ -178,13 +195,65 @@ public class StartupService(IConfigService configService, IAuthService authServi } } - private static async Task GetFfmpegVersionAsync(string ffmpegPath) + private void DetectFfprobe(StartupResult result) + { + if (!string.IsNullOrEmpty(configService.CurrentConfig.FFprobePath) && + ValidateFilePath(configService.CurrentConfig.FFprobePath)) + { + result.FfprobeFound = true; + result.FfprobePath = configService.CurrentConfig.FFprobePath; + Log.Debug($"FFprobe found: {result.FfprobePath}"); + Log.Debug("FFprobe path set in config.conf"); + } + + if (!result.FfprobeFound && !string.IsNullOrEmpty(result.FfmpegPath)) + { + string? ffmpegDirectory = Path.GetDirectoryName(result.FfmpegPath); + if (!string.IsNullOrEmpty(ffmpegDirectory)) + { + string ffprobeFileName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? "ffprobe.exe" + : "ffprobe"; + string inferredFfprobePath = Path.Combine(ffmpegDirectory, ffprobeFileName); + if (ValidateFilePath(inferredFfprobePath)) + { + result.FfprobeFound = true; + result.FfprobePathAutoDetected = true; + result.FfprobePath = inferredFfprobePath; + configService.CurrentConfig.FFprobePath = inferredFfprobePath; + Log.Debug($"FFprobe found: {inferredFfprobePath}"); + Log.Debug("FFprobe path inferred from FFmpeg path"); + } + } + } + + if (!result.FfprobeFound && string.IsNullOrEmpty(configService.CurrentConfig.FFprobePath)) + { + string? ffprobePath = GetFullPath("ffprobe") ?? GetFullPath("ffprobe.exe"); + if (ffprobePath != null) + { + result.FfprobeFound = true; + result.FfprobePathAutoDetected = true; + result.FfprobePath = ffprobePath; + configService.CurrentConfig.FFprobePath = ffprobePath; + Log.Debug($"FFprobe found: {ffprobePath}"); + Log.Debug("FFprobe path found via PATH or current directory"); + } + } + + if (!result.FfprobeFound) + { + Log.Error($"Cannot locate FFprobe with path: {configService.CurrentConfig.FFprobePath}"); + } + } + + private static async Task GetToolVersionAsync(string toolPath, string toolName) { try { ProcessStartInfo processStartInfo = new() { - FileName = ffmpegPath, + FileName = toolPath, Arguments = "-version", RedirectStandardOutput = true, RedirectStandardError = true, @@ -198,12 +267,13 @@ public class StartupService(IConfigService configService, IAuthService authServi string output = await process.StandardOutput.ReadToEndAsync(); await process.WaitForExitAsync(); - Log.Information("FFmpeg version output:\n{Output}", output); + Log.Information("{ToolName} version output:\n{Output}", toolName, output); string firstLine = output.Split('\n')[0].Trim(); - if (firstLine.StartsWith("ffmpeg version")) + string expectedPrefix = $"{toolName} version "; + if (firstLine.StartsWith(expectedPrefix, StringComparison.OrdinalIgnoreCase)) { - int versionStart = "ffmpeg version ".Length; + int versionStart = expectedPrefix.Length; int copyrightIndex = firstLine.IndexOf(" Copyright", StringComparison.Ordinal); return copyrightIndex > versionStart ? firstLine.Substring(versionStart, copyrightIndex - versionStart) @@ -213,7 +283,7 @@ public class StartupService(IConfigService configService, IAuthService authServi } catch (Exception ex) { - Log.Warning(ex, "Failed to get FFmpeg version"); + Log.Warning(ex, "Failed to get {ToolName} version", toolName); } return null; diff --git a/OF DL.Tests/Services/ApiServiceTests.cs b/OF DL.Tests/Services/ApiServiceTests.cs index 54b90dc..7b327ed 100644 --- a/OF DL.Tests/Services/ApiServiceTests.cs +++ b/OF DL.Tests/Services/ApiServiceTests.cs @@ -281,7 +281,7 @@ public class ApiServiceTests } [Fact] - public async Task GetDrmMpdPssh_ReturnsSecondPssh() + public async Task GetDrmMpdInfo_ReturnsSecondPssh() { string mpd = """ @@ -304,14 +304,49 @@ public class ApiServiceTests }; ApiService service = CreateService(authService); - string pssh = await service.GetDrmMpdPssh(server.Url.ToString(), "policy", "signature", "kvp"); + (string pssh, _, _) = await service.GetDrmMpdInfo(server.Url.ToString(), "policy", "signature", "kvp"); await server.Completion; Assert.Equal("SECOND", pssh); } [Fact] - public async Task GetDrmMpdLastModified_ReturnsLastModifiedHeader() + public async Task GetDrmMpdInfo_ReturnsPsshLastModifiedAndDuration() + { + string mpd = """ + + + + + FIRST + SECOND + + + + """; + DateTime lastModifiedUtc = new(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc); + using SimpleHttpServer server = new(mpd, lastModifiedUtc); + FakeAuthService authService = new() + { + CurrentAuth = new Auth + { + UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_id=1; sess=2;" + } + }; + ApiService service = CreateService(authService); + + (string pssh, DateTime lastModified, double? durationSeconds) = + await service.GetDrmMpdInfo(server.Url.ToString(), "policy", "signature", "kvp"); + await server.Completion; + + Assert.Equal("SECOND", pssh); + Assert.True(durationSeconds.HasValue); + Assert.Equal(62.5, durationSeconds.Value, 3); + Assert.True((lastModified - lastModifiedUtc.ToLocalTime()).Duration() < TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task GetDrmMpdInfo_ReturnsLastModifiedHeader() { DateTime lastModifiedUtc = new(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc); using SimpleHttpServer server = new("", lastModifiedUtc); @@ -324,12 +359,12 @@ public class ApiServiceTests }; ApiService service = CreateService(authService); - DateTime result = - await service.GetDrmMpdLastModified(server.Url.ToString(), "policy", "signature", "kvp"); + (_, DateTime lastModified, _) = + await service.GetDrmMpdInfo(server.Url.ToString(), "policy", "signature", "kvp"); await server.Completion; DateTime expectedLocal = lastModifiedUtc.ToLocalTime(); - Assert.True((result - expectedLocal).Duration() < TimeSpan.FromSeconds(1)); + Assert.True((lastModified - expectedLocal).Duration() < TimeSpan.FromSeconds(1)); } [Fact] diff --git a/OF DL.Tests/Services/ConfigServiceTests.cs b/OF DL.Tests/Services/ConfigServiceTests.cs index 7157ee9..4fad420 100644 --- a/OF DL.Tests/Services/ConfigServiceTests.cs +++ b/OF DL.Tests/Services/ConfigServiceTests.cs @@ -21,6 +21,8 @@ public class ConfigServiceTests Assert.True(File.Exists("config.conf")); Assert.True(loggingService.UpdateCount > 0); Assert.Equal(service.CurrentConfig.LoggingLevel, loggingService.LastLevel); + Assert.Equal("", service.CurrentConfig.FFprobePath); + Assert.Equal(0.98, service.CurrentConfig.DrmVideoDurationMatchThreshold, 3); } [Fact] @@ -58,6 +60,26 @@ public class ConfigServiceTests Assert.False(result); } + [Fact] + public async Task LoadConfigurationAsync_ParsesDrmVideoDurationMatchThreshold() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + FakeLoggingService loggingService = new(); + ConfigService service = new(loggingService); + await service.SaveConfigurationAsync(); + + string hocon = await File.ReadAllTextAsync("config.conf"); + hocon = hocon.Replace("DrmVideoDurationMatchThreshold = 0.98", + "DrmVideoDurationMatchThreshold = 0.95"); + await File.WriteAllTextAsync("config.conf", hocon); + + bool result = await service.LoadConfigurationAsync([]); + + Assert.True(result); + Assert.Equal(0.95, service.CurrentConfig.DrmVideoDurationMatchThreshold, 3); + } + [Fact] public void ApplyToggleableSelections_UpdatesConfigAndReturnsChange() { diff --git a/OF DL.Tests/Services/DownloadServiceTests.cs b/OF DL.Tests/Services/DownloadServiceTests.cs index e9ee291..cf5fa5c 100644 --- a/OF DL.Tests/Services/DownloadServiceTests.cs +++ b/OF DL.Tests/Services/DownloadServiceTests.cs @@ -1,3 +1,4 @@ +using System.Reflection; using OF_DL.Models.Config; using OF_DL.Models.Downloads; using OF_DL.Services; @@ -77,7 +78,7 @@ public class DownloadServiceTests DownloadService service = CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); - (string decryptionKey, DateTime lastModified)? result = await service.GetDecryptionInfo( + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo( "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", true, false); @@ -95,7 +96,7 @@ public class DownloadServiceTests DownloadService service = CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); - (string decryptionKey, DateTime lastModified)? result = await service.GetDecryptionInfo( + (string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo( "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", false, false); @@ -106,6 +107,38 @@ public class DownloadServiceTests Assert.False(apiService.OfdlCalled); } + [Fact] + public async Task FinalizeDrmDownload_DoesNotDeleteFile_WhenCustomPathMatchesTempPath() + { + using TempFolder temp = new(); + string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); + string path = "/Posts/Free"; + string filename = "video"; + string customFileName = "video_source"; + string tempFilename = $"{folder}{path}/{filename}_source.mp4"; + Directory.CreateDirectory(Path.GetDirectoryName(tempFilename) ?? throw new InvalidOperationException()); + await File.WriteAllTextAsync(tempFilename, "abc"); + + MediaTrackingDbService dbService = new(); + DownloadService service = CreateService(new FakeConfigService(new Config { ShowScrapeSize = false }), dbService); + ProgressRecorder progress = new(); + + MethodInfo? finalizeMethod = typeof(DownloadService).GetMethod("FinalizeDrmDownload", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(finalizeMethod); + + object? resultObject = finalizeMethod.Invoke(service, + [ + tempFilename, DateTime.UtcNow, folder, path, customFileName, filename, 1L, "Posts", progress + ]); + + bool result = await Assert.IsType>(resultObject!); + Assert.True(result); + Assert.True(File.Exists(tempFilename)); + Assert.NotNull(dbService.LastUpdateMedia); + Assert.Equal("video_source.mp4", dbService.LastUpdateMedia.Value.filename); + } + [Fact] public async Task DownloadHighlights_ReturnsZeroWhenNoMedia() { diff --git a/OF DL.Tests/Services/TestDoubles.cs b/OF DL.Tests/Services/TestDoubles.cs index ba8797c..31b5ca0 100644 --- a/OF DL.Tests/Services/TestDoubles.cs +++ b/OF DL.Tests/Services/TestDoubles.cs @@ -116,11 +116,10 @@ internal sealed class StaticApiService : IApiService public bool CdmCalled { get; private set; } - public Task GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp) => - Task.FromResult("pssh"); - - public Task GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp) => - Task.FromResult(LastModifiedToReturn); + public Task<(string pssh, DateTime lastModified, double? durationSeconds)> GetDrmMpdInfo( + string mpdUrl, string policy, string signature, string kvp) => + Task.FromResult<(string pssh, DateTime lastModified, double? durationSeconds)>( + ("pssh", LastModifiedToReturn, null)); public Task GetDecryptionKeyOfdl(Dictionary drmHeaders, string licenceUrl, string pssh) { @@ -271,10 +270,8 @@ internal sealed class ConfigurableApiService : IApiService public Task GetDecryptionKeyCdm(Dictionary drmHeaders, string licenceUrl, string pssh) => throw new NotImplementedException(); - public Task GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp) => - throw new NotImplementedException(); - - public Task GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp) => + public Task<(string pssh, DateTime lastModified, double? durationSeconds)> GetDrmMpdInfo( + string mpdUrl, string policy, string signature, string kvp) => throw new NotImplementedException(); public Task GetDecryptionKeyOfdl(Dictionary drmHeaders, string licenceUrl, string pssh) => @@ -296,7 +293,7 @@ internal sealed class OrchestrationDownloadServiceStub : IDownloadService string serverFileName, string resolvedFileName, string extension, IProgressReporter progressReporter) => throw new NotImplementedException(); - public Task<(string decryptionKey, DateTime lastModified)?> 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(); diff --git a/OF DL/Program.cs b/OF DL/Program.cs index f43d269..9a3dd1d 100644 --- a/OF DL/Program.cs +++ b/OF DL/Program.cs @@ -172,6 +172,23 @@ public class Program(IServiceProvider serviceProvider) Environment.Exit(4); } + if (!startupResult.FfprobeFound) + { + if (!configService.CurrentConfig.NonInteractiveMode) + { + AnsiConsole.Markup( + "[red]Cannot locate FFprobe; please modify config.conf with the correct path. Press any key to exit.[/]"); + Console.ReadKey(); + } + else + { + AnsiConsole.Markup( + "[red]Cannot locate FFprobe; please modify config.conf with the correct path.[/]"); + } + + Environment.Exit(4); + } + // Auth flow await HandleAuthFlow(authService, configService); @@ -841,24 +858,27 @@ public class Program(IServiceProvider serviceProvider) // FFmpeg if (result.FfmpegFound) { - if (result.FfmpegPathAutoDetected && result.FfmpegPath != null) - { - AnsiConsole.Markup( - $"[green]FFmpeg located successfully. Path auto-detected: {Markup.Escape(result.FfmpegPath)}\n[/]"); - } - else - { - AnsiConsole.Markup("[green]FFmpeg located successfully\n[/]"); - } + AnsiConsole.Markup( + result is { FfmpegPathAutoDetected: true, FfmpegPath: not null } + ? $"[green]FFmpeg located successfully. Path auto-detected: {Markup.Escape(result.FfmpegPath)}\n[/]" + : "[green]FFmpeg located successfully\n[/]"); - if (result.FfmpegVersion != null) - { - AnsiConsole.Markup($"[green]ffmpeg version detected as {Markup.Escape(result.FfmpegVersion)}[/]\n"); - } - else - { - AnsiConsole.Markup("[yellow]ffmpeg version could not be parsed[/]\n"); - } + AnsiConsole.Markup(result.FfmpegVersion != null + ? $"[green]ffmpeg version detected as {Markup.Escape(result.FfmpegVersion)}[/]\n" + : "[yellow]ffmpeg version could not be parsed[/]\n"); + } + + // FFprobe + if (result.FfprobeFound) + { + AnsiConsole.Markup( + result is { FfprobePathAutoDetected: true, FfprobePath: not null } + ? $"[green]FFprobe located successfully. Path auto-detected: {Markup.Escape(result.FfprobePath)}\n[/]" + : "[green]FFprobe located successfully\n[/]"); + + AnsiConsole.Markup(result.FfprobeVersion != null + ? $"[green]FFprobe version detected as {Markup.Escape(result.FfprobeVersion)}[/]\n" + : "[yellow]FFprobe version could not be parsed[/]\n"); } // Widevine diff --git a/docs/config/all-configuration-options.md b/docs/config/all-configuration-options.md index 6769362..2eb387d 100644 --- a/docs/config/all-configuration-options.md +++ b/docs/config/all-configuration-options.md @@ -1,6 +1,8 @@ # All Configuration Options -This page contains detailed information for each configuration option supported by OF-DL. For information about the structure of the `config.conf` file or a simple list of these configuration options, go to the [configuration page](/config/configuration). +This page contains detailed information for each configuration option supported by OF-DL. For information about the +structure of the `config.conf` file or a simple list of these configuration options, go to +the [configuration page](/config/configuration). ## BypassContentForCreatorsWhoNoLongerExist @@ -10,9 +12,12 @@ Default: `false` Allowed values: `true`, `false` -Description: When a creator no longer exists (their account has been deleted), most of their content will be inaccessible. -Purchased content, however, will still be accessible by downloading media usisng the "Download Purchased Tab" menu option -or with the [NonInteractiveModePurchasedTab](#noninteractivemodepurchasedtab) config option when downloading media in non-interactive mode. +Description: When a creator no longer exists (their account has been deleted), most of their content will be +inaccessible. +Purchased content, however, will still be accessible by downloading media usisng the "Download Purchased Tab" menu +option +or with the [NonInteractiveModePurchasedTab](#noninteractivemodepurchasedtab) config option when downloading media in +non-interactive mode. ## CreatorConfigs @@ -23,12 +28,14 @@ Default: `{}` Allowed values: An array of Creator Config objects Description: This configuration options allows you to set file name formats for specific creators. -This is useful if you want to have different file name formats for different creators. The values set here will override the global values set in the config file +This is useful if you want to have different file name formats for different creators. The values set here will override +the global values set in the config file (see [PaidPostFileNameFormat](#paidpostfilenameformat), [PostFileNameFormat](#postfilenameformat), [PaidMessageFileNAmeFormat](#paidmessagefilenameformat), and [MessageFileNameFormat](#messagefilenameformat)). For more information on the file name formats, see the [custom filename formats](/config/custom-filename-formats) page. Example: + ``` "CreatorConfigs": { "creator_one": { @@ -55,7 +62,8 @@ Default: `null` Allowed values: Any date in `yyyy-mm-dd` format or `null` Description: [DownloadOnlySpecificDates](#downloadonlyspecificdates) needs to be set to `true` for this to work. -This date will be used when you are trying to download between/after a certain date. See [DownloadOnlySpecificDates](#downloadonlyspecificdates) and +This date will be used when you are trying to download between/after a certain date. +See [DownloadOnlySpecificDates](#downloadonlyspecificdates) and [DownloadDateSelection](#downloaddateselection) for more information. ## DisableBrowserAuth @@ -71,6 +79,16 @@ an `auth.json` file will need to be provided using a [legacy authentication meth If set to `true`, the `auth.json` file will not be deleted if authentication fails. If set to `false` (the default behavior), OF-DL will delete the `auth.json` file if authentication fails. +## DisableTextSanitization + +Type: `boolean` + +Default: `false` + +Allowed values: `true`, `false` + +Description: When enabled, post/message text is stored as-is without XML stripping. + ## DownloadArchived Type: `boolean` @@ -109,7 +127,8 @@ Default: `"before"` Allowed values: `"before"`, `"after"` -Description: [DownloadOnlySpecificDates](#downloadonlyspecificdates) needs to be set to `true` for this to work. This will get all posts from before +Description: [DownloadOnlySpecificDates](#downloadonlyspecificdates) needs to be set to `true` for this to work. This +will get all posts from before the date if set to `"before"`, and all posts from the date you specify up until the current date if set to `"after"`. The date you specify will be in the [CustomDate](#customdate) config option. @@ -121,7 +140,8 @@ Default: `false` Allowed values: `true`, `false` -Description: By default (or when set to `false`), the program will not download duplicated media. If set to `true`, duplicated media will be downloaded. +Description: By default (or when set to `false`), the program will not download duplicated media. If set to `true`, +duplicated media will be downloaded. ## DownloadHighlights @@ -151,7 +171,8 @@ Default: `4` Allowed values: Any positive integer -Description: The download rate in MB per second. This will only be used if [LimitDownloadRate](#limitdownloadrate) is set to `true`. +Description: The download rate in MB per second. This will only be used if [LimitDownloadRate](#limitdownloadrate) is +set to `true`. ## DownloadMessages @@ -171,7 +192,8 @@ Default: `false` Allowed values: `true`, `false` -Description: If set to `true`, posts will be downloaded based on the [DownloadDateSelection](#downloaddateselection) and [CustomDate](#customdate) config options. +Description: If set to `true`, posts will be downloaded based on the [DownloadDateSelection](#downloaddateselection) +and [CustomDate](#customdate) config options. If set to `false`, all posts will be downloaded. ## DownloadPaidMessages @@ -228,8 +250,10 @@ Default: `false` Allowed values: `true`, `false` -Description: If set to `true`, only new posts will be downloaded from the date of the last post that was downloaded based off what's in the `user_data.db` file. -If set to `false`, the default behaviour will apply, and all posts will be gathered and compared against the database to see if they need to be downloaded or not. +Description: If set to `true`, only new posts will be downloaded from the date of the last post that was downloaded +based off what's in the `user_data.db` file. +If set to `false`, the default behaviour will apply, and all posts will be gathered and compared against the database to +see if they need to be downloaded or not. ## DownloadStories @@ -251,6 +275,17 @@ Allowed values: `true`, `false` Description: Posts in the "Streams" tab will be downloaded if set to `true` +## DownloadVideoResolution + +Type: `string` + +Default: `"source"` + +Allowed values: `"source"`, `"240"`, `"720"` + +Description: This allows you to download videos in alternative resolutions, by default videos are downloaded in source +resolution but some people prefer smoother videos at a lower resolution. + ## DownloadVideos Type: `boolean` @@ -261,6 +296,19 @@ Allowed values: `true`, `false` Description: Videos will be downloaded if set to `true` +## DrmVideoDurationMatchThreshold + +Type: `double` + +Default: `0.98` + +Allowed values: `0.01` to `1.0` + +Description: Minimum required ratio between downloaded DRM video length and expected length. +Expected length is read from the MPD first, with media duration metadata used as a fallback. +For example, `0.98` requires the downloaded file to be at least 98% of the expected duration. +If the download is below this threshold, the program retries the download up to 3 times. + ## FFmpegPath Type: `string` @@ -270,14 +318,30 @@ Default: `""` Allowed values: Any valid path or `""` Description: This is the path to the FFmpeg executable (`ffmpeg.exe` on Windows and `ffmpeg` on Linux/macOS). -If the path is not set then the program will try to find it in both the same directory as the OF-DL executable as well -as the PATH environment variable. +If the path is not set, the program will try to find it in both the same directory as the OF-DL executable and the PATH +environment variable. !!! note If you are using a Windows path, you will need to escape the backslashes, e.g. `"C:\\ffmpeg\\bin\\ffmpeg.exe"` For example, this is not valid: `"C:\some\path\ffmpeg.exe"`, but `"C:/some/path/ffmpeg.exe"` and `"C:\\some\\path\\ffmpeg.exe"` are both valid. +## FFprobePath + +Type: `string` + +Default: `""` + +Allowed values: Any valid path or `""` + +Description: This is the path to the FFprobe executable (`ffprobe.exe` on Windows and `ffprobe` on Linux/macOS). +If the path is not set, the program will try to find it in both the same directory as the OF-DL executable and the PATH +environment variable. + +!!! note + + If you are using a Windows path, you will need to escape the backslashes, e.g. `"C:\\ffmpeg\\bin\\ffprobe.exe"` + ## FolderPerMessage Type: `boolean` @@ -297,7 +361,8 @@ Default: `false` Allowed values: `true`, `false` -Description: A folder will be created for each paid message (containing all the media for that message) if set to `true`. +Description: A folder will be created for each paid message (containing all the media for that message) if set to +`true`. When set to `false`, paid message media will be downloaded into the `Messages/Paid` folder. ## FolderPerPaidPost @@ -330,7 +395,9 @@ Default: `false` Allowed values: `true`, `false` -Description: By default (or when set to `false`), messages that were sent by yourself will be added to the metadata DB and any media which has been sent by yourself will be downloaded. If set to `true`, the program will not add messages sent by yourself to the metadata DB and will not download any media which has been sent by yourself. +Description: By default (or when set to `false`), messages that were sent by yourself will be added to the metadata DB +and any media which has been sent by yourself will be downloaded. If set to `true`, the program will not add messages +sent by yourself to the metadata DB and will not download any media which has been sent by yourself. ## IgnoredUsersListName @@ -361,7 +428,8 @@ Default: `false` Allowed values: `true`, `false` -Description: If set to `true`, media from restricted creators will be downloaded. If set to `false`, restricted creators will be ignored. +Description: If set to `true`, media from restricted creators will be downloaded. If set to `false`, restricted creators +will be ignored. ## LimitDownloadRate @@ -371,7 +439,8 @@ Default: `false` Allowed values: `true`, `false` -Description: If set to `true`, the download rate will be limited to the value set in [DownloadLimitInMbPerSec](#downloadlimitinmbpersec). +Description: If set to `true`, the download rate will be limited to the value set +in [DownloadLimitInMbPerSec](#downloadlimitinmbpersec). ## LoggingLevel @@ -392,7 +461,8 @@ Default: `""` Allowed values: Any valid string -Description: Please refer to [custom filename formats](/config/custom-filename-formats#messagefilenameformat) page to see what fields you can use. +Description: Please refer to [custom filename formats](/config/custom-filename-formats#messagefilenameformat) page to +see what fields you can use. ## NonInteractiveMode @@ -402,8 +472,10 @@ Default: `false` Allowed values: `true`, `false` -Description: If set to `true`, the program will run without any input from the user. It will scrape all users automatically -(unless [NonInteractiveModeListName](#noninteractivemodelistname) or [NonInteractiveModePurchasedTab](#noninteractivemodepurchasedtab) are configured). +Description: If set to `true`, the program will run without any input from the user. It will scrape all users +automatically +(unless [NonInteractiveModeListName](#noninteractivemodelistname) +or [NonInteractiveModePurchasedTab](#noninteractivemodepurchasedtab) are configured). If set to `false`, the default behaviour will apply, and you will be able to choose an option from the menu. !!! warning @@ -414,7 +486,6 @@ If set to `false`, the default behaviour will apply, and you will be able to cho 1. Generate an auth.json file by running OF-DL with NonInteractiveMode disabled and authenticating OF-DL using the standard method **OR** 2. Generate an auth.json file by using a [legacy authentication method](/config/auth#legacy-methods) - ## NonInteractiveModeListName Type: `string` @@ -423,7 +494,8 @@ Default: `""` Allowed values: The name of a list of users you have created on OnlyFans or `""` -Description: When set to the name of a list, non-interactive mode will download media from the list of users instead of all +Description: When set to the name of a list, non-interactive mode will download media from the list of users instead of +all users (when [NonInteractiveMode](#noninteractivemode) is set to `true`). If set to `""`, all users will be scraped (unless [NonInteractiveModePurchasedTab](#noninteractivemodepurchasedtab) is configured). @@ -447,7 +519,8 @@ Default: `""` Allowed values: Any valid string -Description: Please refer to [custom filename formats](/config/custom-filename-formats#paidmessagefilenameformat) page to see what fields you can use. +Description: Please refer to [custom filename formats](/config/custom-filename-formats#paidmessagefilenameformat) page +to see what fields you can use. ## PaidPostFileNameFormat @@ -457,7 +530,8 @@ Default: `""` Allowed values: Any valid string -Description: Please refer to [custom filename formats](/config/custom-filename-formats#paidpostfilenameformat) page to see what fields you can use. +Description: Please refer to [custom filename formats](/config/custom-filename-formats#paidpostfilenameformat) page to +see what fields you can use. ## PostFileNameFormat @@ -467,7 +541,8 @@ Default: `""` Allowed values: Any valid string -Description: Please refer to the [custom filename formats](/config/custom-filename-formats#postfilenameformat) page to see what fields you can use. +Description: Please refer to the [custom filename formats](/config/custom-filename-formats#postfilenameformat) page to +see what fields you can use. ## RenameExistingFilesWhenCustomFormatIsSelected @@ -516,23 +591,3 @@ Allowed values: Any positive integer or `-1` Description: You won't need to set this, but if you see errors about the configured timeout of 100 seconds elapsing then you could set this to be more than 100. It is recommended that you leave this as the default value. - -## DisableTextSanitization - -Type: `boolean` - -Default: `false` - -Allowed values: `true`, `false` - -Description: When enabled, post/message text is stored as-is without XML stripping. - -## DownloadVideoResolution - -Type: `string` - -Default: `"source"` - -Allowed values: `"source"`, `"240"`, `"720"` - -Description: This allows you to download videos in alternative resolutions, by default videos are downloaded in source resolution but some people prefer smoother videos at a lower resolution. \ No newline at end of file diff --git a/docs/config/configuration.md b/docs/config/configuration.md index 6f87744..54ebed2 100644 --- a/docs/config/configuration.md +++ b/docs/config/configuration.md @@ -8,6 +8,7 @@ information about what it does, its default value, and the allowed values. - External - [FFmpegPath](/config/all-configuration-options#ffmpegpath) + - [FFprobePath](/config/all-configuration-options#ffprobepath) - Download - [IgnoreOwnMessages](/config/all-configuration-options#ignoreownmessages) @@ -22,6 +23,7 @@ information about what it does, its default value, and the allowed values. - [ShowScrapeSize](/config/all-configuration-options#showscrapesize) - [DisableTextSanitization](/config/all-configuration-options#disabletextsanitization) - [DownloadVideoResolution](/config/all-configuration-options#downloadvideoresolution) + - [DrmVideoDurationMatchThreshold](/config/all-configuration-options#drmvideodurationmatchthreshold) - Media - [DownloadAvatarHeaderPhoto](/config/all-configuration-options#downloadavatarheaderphoto) - [DownloadPaidPosts](/config/all-configuration-options#downloadpaidposts) diff --git a/docs/installation/windows.md b/docs/installation/windows.md index bd58698..40d7a3f 100644 --- a/docs/installation/windows.md +++ b/docs/installation/windows.md @@ -5,9 +5,9 @@ ### FFmpeg You will need to download FFmpeg. You can download it from [here](https://www.gyan.dev/ffmpeg/builds/). -Make sure you download `ffmpeg-release-essentials.zip`. Unzip it anywhere on your computer. You only need `ffmpeg.exe`, and you can ignore the rest. -Move `ffmpeg.exe` to the same folder as `OF DL.exe` (downloaded in the installation steps below). If you choose to move `ffmpeg.exe` to a different folder, -you will need to specify the path to `ffmpeg.exe` in the config file (see the `FFmpegPath` [config option](/config/configuration#ffmpegpath)). +Make sure you download `ffmpeg-release-essentials.zip`. Unzip it anywhere on your computer. You need both `ffmpeg.exe` and `ffprobe.exe`. +Move `ffmpeg.exe` and `ffprobe.exe` to the same folder as `OF DL.exe` (downloaded in the installation steps below). If you choose to move them to a different folder, +you will need to specify the paths in the config file (see the `FFmpegPath` and `FFprobePath` [config options](/config/configuration)). ## Installation @@ -19,4 +19,5 @@ you will need to specify the path to `ffmpeg.exe` in the config file (see the `F - rules.json - e_sqlite3.dll - ffmpeg.exe + - ffprobe.exe 4. Once you have done this, run OF DL.exe diff --git a/docs/running-the-program.md b/docs/running-the-program.md index 4628a76..bbfae49 100644 --- a/docs/running-the-program.md +++ b/docs/running-the-program.md @@ -4,7 +4,7 @@ Once you are happy you have filled everything in [auth.json](/config/auth) corre ![CLI welcome banner](/img/welcome_banner.png) -It should locate `config.json`, `rules.json` and FFmpeg successfully. If anything doesn't get located +It should locate `config.conf`, `rules.json`, FFmpeg, and FFprobe successfully. If anything doesn't get located successfully, then make sure the files exist or the path is correct. OF-DL will open a new window, if needed, to allow you to log into your OnlyFans account. The window will automatically close once From edc3d771d1e43ee7573b299f01af472b86fb4c58 Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Fri, 13 Feb 2026 00:53:41 -0600 Subject: [PATCH 2/5] Fix misleading wording in download summary messages --- .../Services/DownloadOrchestrationService.cs | 4 ++-- OF DL.Core/Services/DownloadService.cs | 24 ++++++++++--------- OF DL/CLI/SpectreDownloadEventHandler.cs | 2 +- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/OF DL.Core/Services/DownloadOrchestrationService.cs b/OF DL.Core/Services/DownloadOrchestrationService.cs index 2c4b9c5..0549825 100644 --- a/OF DL.Core/Services/DownloadOrchestrationService.cs +++ b/OF DL.Core/Services/DownloadOrchestrationService.cs @@ -627,7 +627,7 @@ public class DownloadOrchestrationService( int objectCount = getObjectCount(data); eventHandler.OnContentFound(contentType, mediaCount, objectCount); - Log.Debug($"Found {mediaCount} Media from {objectCount} {contentType}"); + Log.Debug("Found {MediaCount} Media from {ObjectCount} {ContentType}", mediaCount, objectCount, contentType); Config config = configService.CurrentConfig; List? urls = getUrls(data); @@ -641,7 +641,7 @@ public class DownloadOrchestrationService( eventHandler.OnDownloadComplete(contentType, result); Log.Debug( - $"{contentType} Already Downloaded: {result.ExistingDownloads} New {contentType} Downloaded: {result.NewDownloads}"); + $"{contentType} Media Already Downloaded: {result.ExistingDownloads} New {contentType} Media Downloaded: {result.NewDownloads}"); return result.TotalCount; } diff --git a/OF DL.Core/Services/DownloadService.cs b/OF DL.Core/Services/DownloadService.cs index 235c287..60a6fbb 100644 --- a/OF DL.Core/Services/DownloadService.cs +++ b/OF DL.Core/Services/DownloadService.cs @@ -1246,7 +1246,7 @@ public class DownloadService( } Log.Debug( - $"Highlights Already Downloaded: {oldHighlightsCount} New Highlights Downloaded: {newHighlightsCount}"); + $"Highlights Media Already Downloaded: {oldHighlightsCount} New Highlights Media Downloaded: {newHighlightsCount}"); return new DownloadResult { @@ -1307,7 +1307,8 @@ public class DownloadService( } } - Log.Debug($"Stories Already Downloaded: {oldStoriesCount} New Stories Downloaded: {newStoriesCount}"); + Log.Debug( + $"Stories Media Already Downloaded: {oldStoriesCount} New Stories Media Downloaded: {newStoriesCount}"); return new DownloadResult { @@ -1401,7 +1402,7 @@ public class DownloadService( } Log.Debug( - $"Archived Posts Already Downloaded: {oldArchivedCount} New Archived Posts Downloaded: {newArchivedCount}"); + $"Archived Posts Media Already Downloaded: {oldArchivedCount} New Archived Posts Media Downloaded: {newArchivedCount}"); return new DownloadResult { @@ -1495,7 +1496,8 @@ public class DownloadService( } } - Log.Debug($"Messages Already Downloaded: {oldMessagesCount} New Messages Downloaded: {newMessagesCount}"); + Log.Debug( + $"Messages Media Already Downloaded: {oldMessagesCount} New Messages Media Downloaded: {newMessagesCount}"); return new DownloadResult { @@ -1592,7 +1594,7 @@ public class DownloadService( } } - Log.Debug($"Paid Messages Already Downloaded: {oldCount} New Paid Messages Downloaded: {newCount}"); + Log.Debug($"Paid Messages Media Already Downloaded: {oldCount} New Paid Messages Media Downloaded: {newCount}"); return new DownloadResult { TotalCount = paidMessageCollection.PaidMessages.Count, @@ -1685,7 +1687,7 @@ public class DownloadService( } } - Log.Debug($"Streams Already Downloaded: {oldCount} New Streams Downloaded: {newCount}"); + Log.Debug($"Streams Media Already Downloaded: {oldCount} New Streams Media Downloaded: {newCount}"); return new DownloadResult { TotalCount = streams.Streams.Count, @@ -1778,7 +1780,7 @@ public class DownloadService( } } - Log.Debug($"Posts Already Downloaded: {oldCount} New Posts Downloaded: {newCount}"); + Log.Debug($"Posts Media Already Downloaded: {oldCount} New Posts Media Downloaded: {newCount}"); return new DownloadResult { TotalCount = posts.Posts.Count, @@ -1872,7 +1874,7 @@ public class DownloadService( } } - Log.Debug($"Paid Posts Already Downloaded: {oldCount} New Paid Posts Downloaded: {newCount}"); + Log.Debug($"Paid Posts Media Already Downloaded: {oldCount} New Paid Posts Media Downloaded: {newCount}"); return new DownloadResult { TotalCount = purchasedPosts.PaidPosts.Count, @@ -1957,7 +1959,7 @@ public class DownloadService( } } - Log.Debug($"Paid Posts Already Downloaded: {oldCount} New Paid Posts Downloaded: {newCount}"); + Log.Debug($"Paid Posts Media Already Downloaded: {oldCount} New Paid Posts Media Downloaded: {newCount}"); return new DownloadResult { TotalCount = purchasedPosts?.PaidPosts.Count ?? 0, @@ -2041,7 +2043,7 @@ public class DownloadService( } } - Log.Debug($"Paid Messages Already Downloaded: {oldCount} New Paid Messages Downloaded: {newCount}"); + Log.Debug($"Paid Messages Media Already Downloaded: {oldCount} New Paid Messages Media Downloaded: {newCount}"); return new DownloadResult { TotalCount = paidMessageCollection.PaidMessages.Count, @@ -2270,7 +2272,7 @@ public class DownloadService( int totalCount = singlePaidMessageCollection.PreviewSingleMessages.Count + singlePaidMessageCollection.SingleMessages.Count; - Log.Debug($"Paid Messages Already Downloaded: {totalOld} New Paid Messages Downloaded: {totalNew}"); + Log.Debug($"Paid Messages Media Already Downloaded: {totalOld} New Paid Messages Media Downloaded: {totalNew}"); return new DownloadResult { TotalCount = totalCount, diff --git a/OF DL/CLI/SpectreDownloadEventHandler.cs b/OF DL/CLI/SpectreDownloadEventHandler.cs index 245bf0a..8d14c7a 100644 --- a/OF DL/CLI/SpectreDownloadEventHandler.cs +++ b/OF DL/CLI/SpectreDownloadEventHandler.cs @@ -71,7 +71,7 @@ public class SpectreDownloadEventHandler : IDownloadEventHandler public void OnDownloadComplete(string contentType, DownloadResult result) => AnsiConsole.Markup( - $"[red]{Markup.Escape(contentType)} Already Downloaded: {result.ExistingDownloads} New {Markup.Escape(contentType)} Downloaded: {result.NewDownloads}[/]\n"); + $"[red]{Markup.Escape(contentType)} Media Already Downloaded: {result.ExistingDownloads} New {Markup.Escape(contentType)} Media Downloaded: {result.NewDownloads}[/]\n"); public void OnUserStarting(string username) => AnsiConsole.Markup($"[red]\nScraping Data for {Markup.Escape(username)}\n[/]"); From 03dd66a8428054527e2dddb9a1f4152b7cc0906f Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Mon, 16 Feb 2026 02:56:03 -0600 Subject: [PATCH 3/5] Fix custom filename formats for paid messages and posts, and fix creator config empty strings --- OF DL.Core/Models/Config/Config.cs | 8 +- OF DL.Core/Services/DownloadService.cs | 24 +- OF DL.Core/Services/FileNameService.cs | 14 +- OF DL.Tests/Models/Config/ConfigTests.cs | 122 ++++++++++ OF DL.Tests/Services/DownloadServiceTests.cs | 219 +++++++++++++++++- .../Services/FileNameServiceTestModels.cs | 2 +- OF DL.Tests/Services/FileNameServiceTests.cs | 20 ++ 7 files changed, 388 insertions(+), 21 deletions(-) create mode 100644 OF DL.Tests/Models/Config/ConfigTests.cs diff --git a/OF DL.Core/Models/Config/Config.cs b/OF DL.Core/Models/Config/Config.cs index c6d53fc..2d3ab51 100644 --- a/OF DL.Core/Models/Config/Config.cs +++ b/OF DL.Core/Models/Config/Config.cs @@ -115,22 +115,22 @@ public class Config : IFileNameFormatConfig if (CreatorConfigs.TryGetValue(username, out CreatorConfig? creatorConfig)) { - if (creatorConfig.PaidPostFileNameFormat != null) + if (!string.IsNullOrEmpty(creatorConfig.PaidPostFileNameFormat)) { combinedFilenameFormatConfig.PaidPostFileNameFormat = creatorConfig.PaidPostFileNameFormat; } - if (creatorConfig.PostFileNameFormat != null) + if (!string.IsNullOrEmpty(creatorConfig.PostFileNameFormat)) { combinedFilenameFormatConfig.PostFileNameFormat = creatorConfig.PostFileNameFormat; } - if (creatorConfig.PaidMessageFileNameFormat != null) + if (!string.IsNullOrEmpty(creatorConfig.PaidMessageFileNameFormat)) { combinedFilenameFormatConfig.PaidMessageFileNameFormat = creatorConfig.PaidMessageFileNameFormat; } - if (creatorConfig.MessageFileNameFormat != null) + if (!string.IsNullOrEmpty(creatorConfig.MessageFileNameFormat)) { combinedFilenameFormatConfig.MessageFileNameFormat = creatorConfig.MessageFileNameFormat; } diff --git a/OF DL.Core/Services/DownloadService.cs b/OF DL.Core/Services/DownloadService.cs index 5fedd5a..d22f13d 100644 --- a/OF DL.Core/Services/DownloadService.cs +++ b/OF DL.Core/Services/DownloadService.cs @@ -1363,11 +1363,12 @@ public class DownloadService( PurchasedEntities.ListItem? messageInfo = paidMessageCollection.PaidMessageObjects.FirstOrDefault(p => p.Media?.Any(m => m.Id == kvpEntry.Key) == true); string filenameFormat = - configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).MessageFileNameFormat ?? ""; + configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).PaidMessageFileNameFormat ?? ""; string paidMsgPath = configService.CurrentConfig.FolderPerPaidMessage && messageInfo != null && messageInfo.Id != 0 && messageInfo.CreatedAt is not null ? $"/Messages/Paid/{messageInfo.Id} {messageInfo.CreatedAt.Value:yyyy-MM-dd HH-mm-ss}" : "/Messages/Paid"; + object? messageAuthor = messageInfo?.FromUser ?? (object?)messageInfo?.Author; if (kvpEntry.Value.Contains("cdn3.onlyfans.com/dash/files")) { @@ -1383,12 +1384,12 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, kvpEntry.Key, "Messages", progressReporter, paidMsgPath + "/Videos", filenameFormat, - messageInfo, mediaInfo, messageInfo?.FromUser, users); + messageInfo, mediaInfo, messageAuthor, users); } else { isNew = await DownloadMedia(kvpEntry.Value, path, kvpEntry.Key, "Messages", progressReporter, - paidMsgPath, filenameFormat, messageInfo, mediaInfo, messageInfo?.FromUser, users); + paidMsgPath, filenameFormat, messageInfo, mediaInfo, messageAuthor, users); } if (isNew) @@ -1640,11 +1641,12 @@ public class DownloadService( PurchasedEntities.ListItem? postInfo = purchasedPosts.PaidPostObjects.FirstOrDefault(p => p.Media?.Any(m => m.Id == postKvp.Key) == true); string filenameFormat = - configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).PostFileNameFormat ?? ""; + configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).PaidPostFileNameFormat ?? ""; string paidPostPath = configService.CurrentConfig.FolderPerPaidPost && postInfo != null && postInfo.Id != 0 && postInfo.PostedAt is not null ? $"/Posts/Paid/{postInfo.Id} {postInfo.PostedAt.Value:yyyy-MM-dd HH-mm-ss}" : "/Posts/Paid"; + object? postAuthor = postInfo?.FromUser ?? (object?)postInfo?.Author; if (postKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { @@ -1660,12 +1662,12 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, postKvp.Key, "Posts", progressReporter, paidPostPath + "/Videos", filenameFormat, - postInfo, mediaInfo, postInfo?.FromUser, users); + postInfo, mediaInfo, postAuthor, users); } else { isNew = await DownloadMedia(postKvp.Value, path, postKvp.Key, "Posts", progressReporter, - paidPostPath, filenameFormat, postInfo, mediaInfo, postInfo?.FromUser, users); + paidPostPath, filenameFormat, postInfo, mediaInfo, postAuthor, users); } if (isNew) @@ -1729,6 +1731,7 @@ public class DownloadService( postInfo.Id != 0 && postInfo.PostedAt is not null ? $"/Posts/Paid/{postInfo.Id} {postInfo.PostedAt.Value:yyyy-MM-dd HH-mm-ss}" : "/Posts/Paid"; + object? postAuthor = postInfo?.FromUser ?? (object?)postInfo?.Author; if (purchasedPostKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { @@ -1744,13 +1747,13 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, purchasedPostKvp.Key, "Posts", progressReporter, paidPostPath + "/Videos", filenameFormat, - postInfo, mediaInfo, postInfo?.FromUser, users); + postInfo, mediaInfo, postAuthor, users); } else { isNew = await DownloadMedia(purchasedPostKvp.Value, path, purchasedPostKvp.Key, "Posts", progressReporter, - paidPostPath, filenameFormat, postInfo, mediaInfo, postInfo?.FromUser, users); + paidPostPath, filenameFormat, postInfo, mediaInfo, postAuthor, users); } if (isNew) @@ -1813,6 +1816,7 @@ public class DownloadService( messageInfo.Id != 0 && messageInfo.CreatedAt is not null ? $"/Messages/Paid/{messageInfo.Id} {messageInfo.CreatedAt.Value:yyyy-MM-dd HH-mm-ss}" : "/Messages/Paid"; + object? messageAuthor = messageInfo?.FromUser ?? (object?)messageInfo?.Author; if (paidMessageKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) { @@ -1828,13 +1832,13 @@ public class DownloadService( isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0], drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, paidMessageKvp.Key, "Messages", progressReporter, paidMsgPath + "/Videos", filenameFormat, - messageInfo, mediaInfo, messageInfo?.FromUser, users); + messageInfo, mediaInfo, messageAuthor, users); } else { isNew = await DownloadMedia(paidMessageKvp.Value, path, paidMessageKvp.Key, "Messages", progressReporter, - paidMsgPath, filenameFormat, messageInfo, mediaInfo, messageInfo?.FromUser, users); + paidMsgPath, filenameFormat, messageInfo, mediaInfo, messageAuthor, users); } if (isNew) diff --git a/OF DL.Core/Services/FileNameService.cs b/OF DL.Core/Services/FileNameService.cs index 92414e8..4290c72 100644 --- a/OF DL.Core/Services/FileNameService.cs +++ b/OF DL.Core/Services/FileNameService.cs @@ -200,8 +200,18 @@ public class FileNameService(IAuthService authService) : IFileNameService object? value = source; foreach (string propertyName in propertyPath.Split('.')) { - PropertyInfo property = value?.GetType().GetProperty(propertyName) ?? - throw new ArgumentException($"Property '{propertyName}' not found."); + if (value == null) + { + return null; + } + + PropertyInfo? property = value.GetType().GetProperty(propertyName, + BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (property == null) + { + return null; + } + value = property.GetValue(value); } diff --git a/OF DL.Tests/Models/Config/ConfigTests.cs b/OF DL.Tests/Models/Config/ConfigTests.cs new file mode 100644 index 0000000..4f99a0f --- /dev/null +++ b/OF DL.Tests/Models/Config/ConfigTests.cs @@ -0,0 +1,122 @@ +using OF_DL.Models.Config; + +namespace OF_DL.Tests.Models.Config; + +public class ConfigTests +{ + [Fact] + public void GetCreatorFileNameFormatConfig_UsesCreatorFormatWhenDefined() + { + OF_DL.Models.Config.Config config = new() + { + PaidPostFileNameFormat = "global-paid-post", + PostFileNameFormat = "global-post", + PaidMessageFileNameFormat = "global-paid-message", + MessageFileNameFormat = "global-message", + CreatorConfigs = new Dictionary + { + ["creator"] = new() + { + PaidPostFileNameFormat = "creator-paid-post", + PostFileNameFormat = "creator-post", + PaidMessageFileNameFormat = "creator-paid-message", + MessageFileNameFormat = "creator-message" + } + } + }; + + IFileNameFormatConfig result = config.GetCreatorFileNameFormatConfig("creator"); + + Assert.Equal("creator-paid-post", result.PaidPostFileNameFormat); + Assert.Equal("creator-post", result.PostFileNameFormat); + Assert.Equal("creator-paid-message", result.PaidMessageFileNameFormat); + Assert.Equal("creator-message", result.MessageFileNameFormat); + } + + [Fact] + public void GetCreatorFileNameFormatConfig_FallsBackToGlobalWhenCreatorFormatIsNullOrEmpty() + { + OF_DL.Models.Config.Config config = new() + { + PaidPostFileNameFormat = "global-paid-post", + PostFileNameFormat = "global-post", + PaidMessageFileNameFormat = "global-paid-message", + MessageFileNameFormat = "global-message", + CreatorConfigs = new Dictionary + { + ["creator"] = new() + { + PaidPostFileNameFormat = null, + PostFileNameFormat = "", + PaidMessageFileNameFormat = null, + MessageFileNameFormat = "" + } + } + }; + + IFileNameFormatConfig result = config.GetCreatorFileNameFormatConfig("creator"); + + Assert.Equal("global-paid-post", result.PaidPostFileNameFormat); + Assert.Equal("global-post", result.PostFileNameFormat); + Assert.Equal("global-paid-message", result.PaidMessageFileNameFormat); + Assert.Equal("global-message", result.MessageFileNameFormat); + } + + [Fact] + public void GetCreatorFileNameFormatConfig_UsesGlobalWhenCreatorConfigDoesNotExist() + { + OF_DL.Models.Config.Config config = new() + { + PaidPostFileNameFormat = "global-paid-post", + PostFileNameFormat = "global-post", + PaidMessageFileNameFormat = "global-paid-message", + MessageFileNameFormat = "global-message", + CreatorConfigs = new Dictionary + { + ["other-creator"] = new() + { + PaidPostFileNameFormat = "other-paid-post", + PostFileNameFormat = "other-post", + PaidMessageFileNameFormat = "other-paid-message", + MessageFileNameFormat = "other-message" + } + } + }; + + IFileNameFormatConfig result = config.GetCreatorFileNameFormatConfig("creator"); + + Assert.Equal("global-paid-post", result.PaidPostFileNameFormat); + Assert.Equal("global-post", result.PostFileNameFormat); + Assert.Equal("global-paid-message", result.PaidMessageFileNameFormat); + Assert.Equal("global-message", result.MessageFileNameFormat); + } + + [Fact] + public void GetCreatorFileNameFormatConfig_ReturnsEmptyFormatsWhenCreatorAndGlobalAreUndefined() + { + OF_DL.Models.Config.Config config = new() + { + PaidPostFileNameFormat = "", + PostFileNameFormat = "", + PaidMessageFileNameFormat = "", + MessageFileNameFormat = "", + CreatorConfigs = new Dictionary + { + ["creator"] = new() + { + PaidPostFileNameFormat = "", + PostFileNameFormat = null, + PaidMessageFileNameFormat = "", + MessageFileNameFormat = null + } + } + }; + + IFileNameFormatConfig result = config.GetCreatorFileNameFormatConfig("creator"); + + Assert.True(string.IsNullOrEmpty(result.PaidPostFileNameFormat)); + Assert.True(string.IsNullOrEmpty(result.PostFileNameFormat)); + Assert.True(string.IsNullOrEmpty(result.PaidMessageFileNameFormat)); + Assert.True(string.IsNullOrEmpty(result.MessageFileNameFormat)); + } +} diff --git a/OF DL.Tests/Services/DownloadServiceTests.cs b/OF DL.Tests/Services/DownloadServiceTests.cs index e9ee291..d9045ef 100644 --- a/OF DL.Tests/Services/DownloadServiceTests.cs +++ b/OF DL.Tests/Services/DownloadServiceTests.cs @@ -1,5 +1,9 @@ using OF_DL.Models.Config; using OF_DL.Models.Downloads; +using OF_DL.Models; +using PostEntities = OF_DL.Models.Entities.Posts; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; +using MessageEntities = OF_DL.Models.Entities.Messages; using OF_DL.Services; namespace OF_DL.Tests.Services; @@ -150,11 +154,218 @@ public class DownloadServiceTests Assert.Equal(2, progress.Total); } + [Fact] + public async Task DownloadFreePosts_UsesDefaultFilenameWhenNoGlobalOrCreatorFormatIsDefined() + { + using TempFolder temp = new(); + string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); + const string serverFilename = "server-name"; + string existingFilePath = $"{folder}/Posts/Free/Images/{serverFilename}.jpg"; + Directory.CreateDirectory(Path.GetDirectoryName(existingFilePath) ?? throw new InvalidOperationException()); + await File.WriteAllTextAsync(existingFilePath, "abc"); + + Config config = new() + { + ShowScrapeSize = false, + PostFileNameFormat = "", + CreatorConfigs = new Dictionary + { + ["creator"] = new() { PostFileNameFormat = "" } + } + }; + + MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; + DownloadService service = CreateService(new FakeConfigService(config), dbService); + ProgressRecorder progress = new(); + PostEntities.PostCollection posts = new() + { + Posts = new Dictionary { { 1, $"https://example.com/{serverFilename}.jpg" } } + }; + + DownloadResult result = await service.DownloadFreePosts("creator", 1, folder, new Dictionary(), + false, false, posts, progress); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(0, result.NewDownloads); + Assert.Equal(1, result.ExistingDownloads); + Assert.NotNull(dbService.LastUpdateMedia); + Assert.Equal($"{serverFilename}.jpg", dbService.LastUpdateMedia.Value.filename); + Assert.Equal(1, progress.Total); + } + + [Fact] + public async Task DownloadFreePosts_UsesGlobalCustomFormatWhenCreatorCustomFormatNotDefined() + { + using TempFolder temp = new(); + string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); + const string serverFilename = "server-name"; + string existingFilePath = $"{folder}/Posts/Free/Images/{serverFilename}.jpg"; + Directory.CreateDirectory(Path.GetDirectoryName(existingFilePath) ?? throw new InvalidOperationException()); + await File.WriteAllTextAsync(existingFilePath, "abc"); + + Config config = new() + { + ShowScrapeSize = false, + PostFileNameFormat = "global-custom-name", + CreatorConfigs = new Dictionary + { + ["creator"] = new() { PostFileNameFormat = "" } + } + }; + + MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; + DownloadService service = CreateService(new FakeConfigService(config), dbService, + fileNameService: new DeterministicFileNameService()); + ProgressRecorder progress = new(); + PostEntities.PostCollection posts = CreatePostCollection(1, serverFilename); + + DownloadResult result = await service.DownloadFreePosts("creator", 1, folder, new Dictionary(), + false, false, posts, progress); + + string renamedPath = $"{folder}/Posts/Free/Images/global-custom-name.jpg"; + Assert.Equal(1, result.TotalCount); + Assert.Equal(0, result.NewDownloads); + Assert.Equal(1, result.ExistingDownloads); + Assert.False(File.Exists(existingFilePath)); + Assert.True(File.Exists(renamedPath)); + Assert.NotNull(dbService.LastUpdateMedia); + Assert.Equal("global-custom-name.jpg", dbService.LastUpdateMedia.Value.filename); + Assert.Equal(1, progress.Total); + } + + [Fact] + public async Task DownloadFreePosts_UsesCreatorCustomFormatWhenGlobalAndCreatorFormatsAreDefined() + { + using TempFolder temp = new(); + string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); + const string serverFilename = "server-name"; + string existingFilePath = $"{folder}/Posts/Free/Images/{serverFilename}.jpg"; + Directory.CreateDirectory(Path.GetDirectoryName(existingFilePath) ?? throw new InvalidOperationException()); + await File.WriteAllTextAsync(existingFilePath, "abc"); + + Config config = new() + { + ShowScrapeSize = false, + PostFileNameFormat = "global-custom-name", + CreatorConfigs = new Dictionary + { + ["creator"] = new() { PostFileNameFormat = "creator-custom-name" } + } + }; + + MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; + DownloadService service = CreateService(new FakeConfigService(config), dbService, + fileNameService: new DeterministicFileNameService()); + ProgressRecorder progress = new(); + PostEntities.PostCollection posts = CreatePostCollection(1, serverFilename); + + DownloadResult result = await service.DownloadFreePosts("creator", 1, folder, new Dictionary(), + false, false, posts, progress); + + string renamedPath = $"{folder}/Posts/Free/Images/creator-custom-name.jpg"; + Assert.Equal(1, result.TotalCount); + Assert.Equal(0, result.NewDownloads); + Assert.Equal(1, result.ExistingDownloads); + Assert.False(File.Exists(existingFilePath)); + Assert.True(File.Exists(renamedPath)); + Assert.NotNull(dbService.LastUpdateMedia); + Assert.Equal("creator-custom-name.jpg", dbService.LastUpdateMedia.Value.filename); + Assert.Equal(1, progress.Total); + } + + [Fact] + public async Task DownloadPaidPosts_AppliesPaidCustomFormatForDrm_WhenAuthorExistsButFromUserMissing() + { + using TempFolder temp = new(); + string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); + const string customName = "paid-custom-name"; + const string drmBaseFilename = "video-file"; + string basePath = $"{folder}/Posts/Paid/Videos"; + Directory.CreateDirectory(basePath); + await File.WriteAllTextAsync($"{basePath}/{customName}.mp4", "custom"); + await File.WriteAllTextAsync($"{basePath}/{drmBaseFilename}_source.mp4", "server"); + + Config config = new() { ShowScrapeSize = false, PaidPostFileNameFormat = customName, PostFileNameFormat = "" }; + + MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; + StaticApiService apiService = new(); + FakeAuthService authService = new() + { + CurrentAuth = new Auth { Cookie = "sess=test;", UserAgent = "unit-test-agent" } + }; + + DownloadService service = CreateService(new FakeConfigService(config), dbService, + apiService, new DeterministicFileNameService(), authService); + ProgressRecorder progress = new(); + PurchasedEntities.PaidPostCollection posts = CreatePaidPostCollectionForDrm(1, + $"https://cdn3.onlyfans.com/dash/files/{drmBaseFilename}.mpd,policy,signature,kvp,1,2"); + + DownloadResult result = await service.DownloadPaidPosts("creator", 1, folder, new Dictionary(), + false, false, posts, progress); + + Assert.Equal(1, result.TotalCount); + Assert.Equal(0, result.NewDownloads); + Assert.Equal(1, result.ExistingDownloads); + Assert.NotNull(dbService.LastUpdateMedia); + Assert.Equal($"{customName}.mp4", dbService.LastUpdateMedia.Value.filename); + Assert.Equal(1, progress.Total); + } + private static DownloadService CreateService(FakeConfigService configService, MediaTrackingDbService dbService, - StaticApiService? apiService = null) => - new(new FakeAuthService(), configService, dbService, new FakeFileNameService(), + StaticApiService? apiService = null, IFileNameService? fileNameService = null, + IAuthService? authService = null) => + new(authService ?? new FakeAuthService(), configService, dbService, + fileNameService ?? new FakeFileNameService(), apiService ?? new StaticApiService()); - private static string NormalizeFolder(string folder) => folder.Replace("\\", "/"); -} + private static PostEntities.PostCollection CreatePostCollection(long mediaId, string serverFilename) + { + PostEntities.Medium media = new() { Id = mediaId }; + PostEntities.ListItem post = new() + { + Id = 10, + PostedAt = new DateTime(2024, 1, 1), + Author = new OF_DL.Models.Entities.Common.Author { Id = 99 }, + Media = [media] + }; + return new PostEntities.PostCollection + { + Posts = new Dictionary { { mediaId, $"https://example.com/{serverFilename}.jpg" } }, + PostMedia = [media], + PostObjects = [post] + }; + } + + private static PurchasedEntities.PaidPostCollection CreatePaidPostCollectionForDrm(long mediaId, string drmUrl) + { + MessageEntities.Medium media = new() { Id = mediaId }; + PurchasedEntities.ListItem post = new() + { + Id = 20, + PostedAt = new DateTime(2024, 1, 1), + Author = new OF_DL.Models.Entities.Common.Author { Id = 99 }, + FromUser = null, + Media = [media] + }; + + return new PurchasedEntities.PaidPostCollection + { + PaidPosts = new Dictionary { { mediaId, drmUrl } }, + PaidPostMedia = [media], + PaidPostObjects = [post] + }; + } + + private static string NormalizeFolder(string folder) => folder.Replace("\\", "/"); + + private sealed class DeterministicFileNameService : IFileNameService + { + public Task BuildFilename(string fileFormat, Dictionary values) => + Task.FromResult(fileFormat); + + public Task> GetFilename(object info, object media, object author, + List selectedProperties, string username, Dictionary? users = null) => + Task.FromResult(new Dictionary()); + } +} diff --git a/OF DL.Tests/Services/FileNameServiceTestModels.cs b/OF DL.Tests/Services/FileNameServiceTestModels.cs index 9d1098b..6e961e8 100644 --- a/OF DL.Tests/Services/FileNameServiceTestModels.cs +++ b/OF DL.Tests/Services/FileNameServiceTestModels.cs @@ -20,7 +20,7 @@ internal sealed class TestMedia internal sealed class TestMediaFiles { - public TestMediaFull Full { get; set; } = new(); + public TestMediaFull? Full { get; set; } = new(); public object? Drm { get; set; } } diff --git a/OF DL.Tests/Services/FileNameServiceTests.cs b/OF DL.Tests/Services/FileNameServiceTests.cs index 3d8805d..a3686fc 100644 --- a/OF DL.Tests/Services/FileNameServiceTests.cs +++ b/OF DL.Tests/Services/FileNameServiceTests.cs @@ -76,4 +76,24 @@ public class FileNameServiceTests Assert.Equal("creator_99", result); } + + [Fact] + public async Task GetFilename_UsesDrmFilenameWhenFullUrlMissing() + { + TestMedia media = new() + { + Id = 99, + Files = new TestMediaFiles + { + Full = null, + Drm = new { Manifest = new { Dash = "https://cdn.test/drm-name.mpd" } } + } + }; + FileNameService service = new(new FakeAuthService()); + + Dictionary values = + await service.GetFilename(new TestInfo(), media, new TestAuthor(), ["filename"], "creator"); + + Assert.Equal("drm-name_source", values["filename"]); + } } From 378a82548b7f4526a2359cc03641909c19562c48 Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Tue, 17 Feb 2026 12:37:28 -0600 Subject: [PATCH 4/5] Update linux installation docs --- docs/installation/linux.md | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/installation/linux.md b/docs/installation/linux.md index faab242..123f973 100644 --- a/docs/installation/linux.md +++ b/docs/installation/linux.md @@ -1,4 +1,4 @@ -# Linux +# Linux A Linux release of OF-DL is not available at this time, however you can run OF-DL on Linux using Docker. Please refer to the [Docker](/installation/docker) page for instructions on how to run OF-DL in a Docker container. @@ -7,18 +7,17 @@ If you would like to run OF-DL natively on Linux, you can build it from source b ## Building from source -- Install the libicu library +- Install FFmpeg (and FFprobe) -```bash -sudo apt-get install libicu-dev -``` +Follow the installtion instructions from FFmpeg ([https://ffmpeg.org/download.html](https://ffmpeg.org/download.html)) for your distro (Ubuntu, Debian, Fedora, etc.) to install FFmpeg and FFprobe -- Install .NET version 8 +!!! warning -```bash - wget https://dot.net/v1/dotnet-install.sh - sudo bash dotnet-install.sh --architecture x64 --install-dir /usr/share/dotnet/ --runtime dotnet --version 8.0.7 -``` + Be sure to install FFmpeg version >= 6 and < 8. Other versions of FFmpeg may not decrypt DRM protected videos correctly. + +- Install .NET 10 + +Follow the installation instructions from Microsoft ([https://learn.microsoft.com/en-us/dotnet/core/install/linux](https://learn.microsoft.com/en-us/dotnet/core/install/linux)) for your distro (Ubuntu, Debian, Fedora, etc.) to install .NET 10. - Clone the repo @@ -27,15 +26,16 @@ git clone https://git.ofdl.tools/sim0n00ps/OF-DL.git cd 'OF-DL' ``` -- Build the project. Replace `%VERSION%` with the current version number of OF-DL (e.g. `1.7.68`). +- Build the project. Replace `%VERSION%` with the current version number of OF-DL (e.g. `1.9.20`). ```bash -dotnet publish -p:Version=%VERSION% -c Release -cd 'OF DL/bin/Release/net8.0' +dotnet publish "OF DL/OF DL.csproj" -p:Version=%VERSION% -p:PackageVersion=%VERSION% -c Release +cd 'OF DL/bin/Release/net10.0' ``` - Download the windows release as described on [here](/installation/windows#installation). -- Add the `config.json` and `rules.json` files as well as the `cdm` folder to the `OF DL/bin/Release/net8.0` folder. + +- Add the `config.conf` and `rules.json` files as well as the `cdm` folder to the `OF DL/bin/Release/net10.0` folder. - Run the application From dce7e7a6bdfc00f773a2e5e6e5bc945639b55820 Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Wed, 18 Feb 2026 02:30:47 -0600 Subject: [PATCH 5/5] Detect if a single post is paid or free --- OF DL.Core/Services/DownloadService.cs | 49 ++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/OF DL.Core/Services/DownloadService.cs b/OF DL.Core/Services/DownloadService.cs index 5fedd5a..91e09aa 100644 --- a/OF DL.Core/Services/DownloadService.cs +++ b/OF DL.Core/Services/DownloadService.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using System.Text.RegularExpressions; using FFmpeg.NET; @@ -1882,6 +1883,7 @@ public class DownloadService( } int oldCount = 0, newCount = 0; + bool hasPaidPostMedia = false; foreach (KeyValuePair postKvp in post.SinglePosts) { @@ -1889,11 +1891,23 @@ public class DownloadService( PostEntities.SinglePost? postInfo = mediaInfo == null ? null : post.SinglePostObjects.FirstOrDefault(p => p.Media?.Contains(mediaInfo) == true); - string filenameFormat = configService.CurrentConfig.GetCreatorFileNameFormatConfig(username) - .PostFileNameFormat ?? ""; - string postPath = configService.CurrentConfig.FolderPerPost && postInfo != null && postInfo.Id != 0 - ? $"/Posts/Free/{postInfo.Id} {postInfo.PostedAt:yyyy-MM-dd HH-mm-ss}" - : "/Posts/Free"; + + bool isPaidPost = IsPaidSinglePost(postInfo); + if (isPaidPost) + { + hasPaidPostMedia = true; + } + + string filenameFormat = hasPaidPostMedia + ? configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).PaidPostFileNameFormat ?? "" + : configService.CurrentConfig.GetCreatorFileNameFormatConfig(username).PostFileNameFormat ?? ""; + string postPath = hasPaidPostMedia + ? configService.CurrentConfig.FolderPerPaidPost && postInfo != null && postInfo.Id != 0 + ? $"/Posts/Paid/{postInfo.Id} {postInfo.PostedAt:yyyy-MM-dd HH-mm-ss}" + : "/Posts/Paid" + : configService.CurrentConfig.FolderPerPost && postInfo != null && postInfo.Id != 0 + ? $"/Posts/Free/{postInfo.Id} {postInfo.PostedAt:yyyy-MM-dd HH-mm-ss}" + : "/Posts/Free"; bool isNew; if (postKvp.Value.Contains("cdn3.onlyfans.com/dash/files")) @@ -1942,11 +1956,34 @@ public class DownloadService( TotalCount = post.SinglePosts.Count, NewDownloads = newCount, ExistingDownloads = oldCount, - MediaType = "Posts", + MediaType = hasPaidPostMedia ? "Paid Posts" : "Posts", Success = true }; } + private static bool IsPaidSinglePost(PostEntities.SinglePost? postInfo) + { + if (postInfo == null || !postInfo.IsOpened) + { + return false; + } + + if (string.IsNullOrWhiteSpace(postInfo.Price)) + { + return false; + } + + string normalizedPrice = postInfo.Price.Trim(); + if (decimal.TryParse(normalizedPrice, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal amount)) + { + return amount > 0; + } + + return !string.Equals(normalizedPrice, "0", StringComparison.OrdinalIgnoreCase) && + !string.Equals(normalizedPrice, "0.0", StringComparison.OrdinalIgnoreCase) && + !string.Equals(normalizedPrice, "0.00", StringComparison.OrdinalIgnoreCase); + } + /// /// Downloads a single paid message collection (including previews). ///