Merge remote-tracking branch 'sim0n00ps/master' into replace-puppeteer-with-playwright

# Conflicts:
#	.gitea/workflows/publish-release.yml
This commit is contained in:
whimsical-c4lic0 2026-02-19 12:25:27 -06:00
commit 22ad1c005b
26 changed files with 1174 additions and 318 deletions

View File

@ -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 chromium-scripts ffmpeg.exe LICENSE.ffmpeg
zip ../OFDLV${{ steps.version.outputs.version }}.zip OF\ DL.exe e_sqlite3.dll rules.json config.conf cdm chromium-scripts ffmpeg.exe ffprobe.exe LICENSE.ffmpeg
cd ..
- name: Create release and upload artifact

View File

@ -9,4 +9,6 @@ public static class Constants
public const int WidevineRetryDelay = 10;
public const int WidevineMaxRetries = 3;
public const int DrmDownloadMaxRetries = 3;
}

View File

@ -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; }
@ -115,22 +118,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;
}

View File

@ -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; }

View File

@ -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,
/// <summary>
/// Retrieves the Widevine PSSH from an MPD manifest.
/// Retrieves DRM metadata (PSSH, Last-Modified, and duration) from an MPD manifest
/// </summary>
/// <param name="mpdUrl">The MPD URL.</param>
/// <param name="policy">CloudFront policy token.</param>
/// <param name="signature">CloudFront signature token.</param>
/// <param name="kvp">CloudFront key pair ID.</param>
/// <returns>The PSSH value or an empty string.</returns>
public async Task<string> GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp)
/// <returns>Tuple with PSSH, Last-Modified, and duration seconds.</returns>
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();
DateTime lastModified = response.Content.Headers.LastModified?.LocalDateTime ?? DateTime.Now;
if (response.Content.Headers.LastModified == null
&& response.Headers.TryGetValues("Last-Modified", out IEnumerable<string>? lastModifiedValues))
{
string? lastModifiedRaw = lastModifiedValues.FirstOrDefault();
if (!string.IsNullOrWhiteSpace(lastModifiedRaw)
&& DateTimeOffset.TryParse(lastModifiedRaw, out DateTimeOffset parsedLastModified))
{
lastModified = parsedLastModified.LocalDateTime;
}
}
string body = await response.Content.ReadAsStringAsync();
XNamespace cenc = "urn:mpeg:cenc:2013";
XDocument xmlDoc = XDocument.Parse(body);
IEnumerable<XElement> psshElements = xmlDoc.Descendants(cenc + "pssh");
string pssh = psshElements.ElementAt(1).Value;
return pssh;
XNamespace cenc = "urn:mpeg:cenc:2013";
List<XElement> psshElements = xmlDoc.Descendants(cenc + "pssh").ToList();
string pssh = psshElements.Skip(1).FirstOrDefault()?.Value
?? psshElements.FirstOrDefault()?.Value
?? string.Empty;
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 string.Empty;
}
/// <summary>
/// Retrieves the Last-Modified timestamp for an MPD manifest.
/// </summary>
/// <param name="mpdUrl">The MPD URL.</param>
/// <param name="policy">CloudFront policy token.</param>
/// <param name="signature">CloudFront signature token.</param>
/// <param name="kvp">CloudFront key pair ID.</param>
/// <returns>The last modified timestamp.</returns>
public async Task<DateTime> 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)
{
throw new Exception("Auth service is missing required fields");
}
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;
Log.Debug($"Last modified: {lastmodified}");
return lastmodified;
}
catch (Exception ex)
{
ExceptionLoggerHelper.LogException(ex);
}
return DateTime.Now;
return (string.Empty, DateTime.Now, null);
}
/// <summary>
@ -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);

View File

@ -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<VideoResolution>("_" + 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);
}

View File

@ -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 =>
@ -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<string>? 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;
}

View File

@ -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<bool> 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<bool> 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<bool>();
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,6 +176,28 @@ public class DownloadService(
$"CloudFront-Key-Pair-Id={kvp}; " +
$"{sess}";
if (expectedDurationSeconds.HasValue)
{
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);
}
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<bool>();
string parameters =
$"{logLevelArgs} " +
$"-cenc_decryption_key {decKey} " +
@ -181,56 +212,213 @@ public class DownloadService(
"-c copy " +
$"\"{tempFilename}\"";
Log.Debug($"Calling FFMPEG with Parameters: {parameters}");
Log.Debug("Calling FFmpeg with Parameters: {Parameters}", parameters);
Engine ffmpeg = new(configService.CurrentConfig.FFmpegPath);
ffmpeg.Error += OnError;
ffmpeg.Complete += async (_, _) =>
{
_completionSource.TrySetResult(true);
await OnFFMPEGDownloadComplete(tempFilename, lastModified, folder, path, customFileName, filename,
mediaId, apiType, progressReporter);
};
ffmpeg.Complete += (_, _) => { _completionSource.TrySetResult(true); };
await ffmpeg.ExecuteAsync(parameters, CancellationToken.None);
return await _completionSource.Task;
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;
}
}
private async Task OnFFMPEGDownloadComplete(string tempFilename, DateTime lastModified, string folder, string path,
private async Task<double?> 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<string> outputTask = process.StandardOutput.ReadToEndAsync();
Task<string> 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<bool> 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(
/// <param name="postMedia">Media info.</param>
/// <param name="author">Author info.</param>
/// <param name="users">Known users map.</param>
/// <param name="expectedDurationSeconds">The expected duration of the video in seconds.</param>
/// <returns>True when the media is newly downloaded.</returns>
private async Task<bool> 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<string, long> users)
object? author, Dictionary<string, long> 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(
/// <param name="drmType">The DRM type.</param>
/// <param name="clientIdBlobMissing">Whether the CDM client ID blob is missing.</param>
/// <param name="devicePrivateKeyMissing">Whether the CDM private key is missing.</param>
/// <returns>The decryption key and last modified timestamp.</returns>
public async Task<(string decryptionKey, DateTime lastModified)?> GetDecryptionInfo(
/// <returns>The decryption key, last modified timestamp, and MPD duration seconds.</returns>
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<string, string> 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);
}
/// <summary>
@ -1058,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
{
@ -1119,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
{
@ -1182,7 +1371,8 @@ 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],
(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)
@ -1193,7 +1383,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
{
@ -1212,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
{
@ -1276,7 +1466,8 @@ 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],
(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)
@ -1287,7 +1478,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
{
@ -1305,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
{
@ -1363,16 +1555,18 @@ 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"))
{
string[] parsed = kvpEntry.Value.Split(',');
(string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1],
(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)
@ -1383,12 +1577,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, drmInfo.Value.mpdDurationSeconds);
}
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)
@ -1401,7 +1595,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,
@ -1464,7 +1658,8 @@ 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],
(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)
@ -1475,7 +1670,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
{
@ -1493,7 +1688,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,
@ -1556,7 +1751,8 @@ 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],
(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)
@ -1567,7 +1763,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
{
@ -1585,7 +1781,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,
@ -1640,16 +1836,18 @@ 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"))
{
string[] parsed = postKvp.Value.Split(',');
(string decryptionKey, DateTime lastModified)? drmInfo = await GetDecryptionInfo(parsed[0], parsed[1],
(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)
@ -1660,12 +1858,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, drmInfo.Value.mpdDurationSeconds);
}
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)
@ -1678,7 +1876,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,
@ -1729,11 +1927,12 @@ 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"))
{
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,13 +1943,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, drmInfo.Value.mpdDurationSeconds);
}
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)
@ -1763,7 +1962,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,
@ -1813,11 +2012,12 @@ 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"))
{
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,13 +2028,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, drmInfo.Value.mpdDurationSeconds);
}
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)
@ -1847,7 +2047,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,
@ -1882,6 +2082,7 @@ public class DownloadService(
}
int oldCount = 0, newCount = 0;
bool hasPaidPostMedia = false;
foreach (KeyValuePair<long, string> postKvp in post.SinglePosts)
{
@ -1889,9 +2090,21 @@ 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
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";
@ -1899,7 +2112,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 +2123,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
{
@ -1942,11 +2155,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);
}
/// <summary>
/// Downloads a single paid message collection (including previews).
/// </summary>
@ -1989,7 +2225,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 +2236,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 +2279,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 +2290,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
{
@ -2076,7 +2312,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,

View File

@ -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);
}

View File

@ -17,14 +17,10 @@ public interface IApiService
Task<string> GetDecryptionKeyCdm(Dictionary<string, string> drmHeaders, string licenceUrl, string pssh);
/// <summary>
/// Retrieves the last modified timestamp for a DRM MPD manifest.
/// Retrieves DRM MPD metadata from a single request.
/// </summary>
Task<DateTime> GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp);
/// <summary>
/// Retrieves the Widevine PSSH from an MPD manifest.
/// </summary>
Task<string> 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);
/// <summary>
/// Retrieves the user's lists.

View File

@ -23,7 +23,7 @@ public interface IDownloadService
/// <summary>
/// Retrieves decryption information for a DRM media item.
/// </summary>
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);

View File

@ -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<string?> 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<string?> 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;

View File

@ -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<string, CreatorConfig>
{
["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<string, CreatorConfig>
{
["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<string, CreatorConfig>
{
["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<string, CreatorConfig>
{
["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));
}
}

View File

@ -281,7 +281,7 @@ public class ApiServiceTests
}
[Fact]
public async Task GetDrmMpdPssh_ReturnsSecondPssh()
public async Task GetDrmMpdInfo_ReturnsSecondPssh()
{
string mpd = """
<?xml version="1.0" encoding="UTF-8"?>
@ -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 = """
<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns:cenc="urn:mpeg:cenc:2013" mediaPresentationDuration="PT1M2.5S">
<Period>
<ContentProtection>
<cenc:pssh>FIRST</cenc:pssh>
<cenc:pssh>SECOND</cenc:pssh>
</ContentProtection>
</Period>
</MPD>
""";
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("<MPD />", 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]

View File

@ -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()
{

View File

@ -1,5 +1,10 @@
using System.Reflection;
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;
@ -77,7 +82,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 +100,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 +111,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<Task<bool>>(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()
{
@ -150,11 +187,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<string, CreatorConfig>
{
["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<long, string> { { 1, $"https://example.com/{serverFilename}.jpg" } }
};
DownloadResult result = await service.DownloadFreePosts("creator", 1, folder, new Dictionary<string, long>(),
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<string, CreatorConfig>
{
["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<string, long>(),
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<string, CreatorConfig>
{
["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<string, long>(),
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<string, long>(),
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<long, string> { { 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<long, string> { { mediaId, drmUrl } },
PaidPostMedia = [media],
PaidPostObjects = [post]
};
}
private static string NormalizeFolder(string folder) => folder.Replace("\\", "/");
private sealed class DeterministicFileNameService : IFileNameService
{
public Task<string> BuildFilename(string fileFormat, Dictionary<string, string> values) =>
Task.FromResult(fileFormat);
public Task<Dictionary<string, string>> GetFilename(object info, object media, object author,
List<string> selectedProperties, string username, Dictionary<string, long>? users = null) =>
Task.FromResult(new Dictionary<string, string>());
}
}

View File

@ -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; }
}

View File

@ -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<string, string> values =
await service.GetFilename(new TestInfo(), media, new TestAuthor(), ["filename"], "creator");
Assert.Equal("drm-name_source", values["filename"]);
}
}

View File

@ -116,11 +116,10 @@ internal sealed class StaticApiService : IApiService
public bool CdmCalled { get; private set; }
public Task<string> GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp) =>
Task.FromResult("pssh");
public Task<DateTime> 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<string> GetDecryptionKeyOfdl(Dictionary<string, string> drmHeaders, string licenceUrl, string pssh)
{
@ -271,10 +270,8 @@ internal sealed class ConfigurableApiService : IApiService
public Task<string> GetDecryptionKeyCdm(Dictionary<string, string> drmHeaders, string licenceUrl, string pssh) =>
throw new NotImplementedException();
public Task<DateTime> GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp) =>
throw new NotImplementedException();
public Task<string> 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<string> GetDecryptionKeyOfdl(Dictionary<string, string> 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();

View File

@ -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[/]");

View File

@ -170,6 +170,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);
@ -838,25 +855,28 @@ 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[/]");
result is { FfmpegPathAutoDetected: true, FfmpegPath: not null }
? $"[green]FFmpeg located successfully. Path auto-detected: {Markup.Escape(result.FfmpegPath)}\n[/]"
: "[green]FFmpeg located successfully\n[/]");
AnsiConsole.Markup(result.FfmpegVersion != null
? $"[green]ffmpeg version detected as {Markup.Escape(result.FfmpegVersion)}[/]\n"
: "[yellow]ffmpeg version could not be parsed[/]\n");
}
if (result.FfmpegVersion != null)
// FFprobe
if (result.FfprobeFound)
{
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 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

View File

@ -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.

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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