forked from sim0n00ps/OF-DL
Compare downloaded DRM video durations against the duration reported by the MPD to ensure complete downloads
This commit is contained in:
parent
aee920a9f1
commit
b4aac13bc6
@ -52,12 +52,13 @@ jobs:
|
||||
echo "➤ Creating folder for CDM"
|
||||
mkdir -p cdm/devices/chrome_1610
|
||||
|
||||
echo "➤ Copying ffmpeg from user folder"
|
||||
echo "➤ Copying ffmpeg and ffprobe from user folder"
|
||||
cp /home/rhys/ffmpeg/ffmpeg-7.1.1-essentials_build/bin/ffmpeg.exe .
|
||||
cp /home/rhys/ffmpeg/ffmpeg-7.1.1-essentials_build/bin/ffprobe.exe .
|
||||
cp /home/rhys/ffmpeg/ffmpeg-7.1.1-essentials_build/LICENSE LICENSE.ffmpeg
|
||||
|
||||
echo "➤ Creating release zip"
|
||||
zip ../OFDLV${{ steps.version.outputs.version }}.zip OF\ DL.exe e_sqlite3.dll rules.json config.conf cdm ffmpeg.exe LICENSE.ffmpeg
|
||||
zip ../OFDLV${{ steps.version.outputs.version }}.zip OF\ DL.exe e_sqlite3.dll rules.json config.conf cdm ffmpeg.exe ffprobe.exe LICENSE.ffmpeg
|
||||
cd ..
|
||||
|
||||
- name: Create release and upload artifact
|
||||
|
||||
@ -9,4 +9,6 @@ public static class Constants
|
||||
public const int WidevineRetryDelay = 10;
|
||||
|
||||
public const int WidevineMaxRetries = 3;
|
||||
|
||||
public const int DrmDownloadMaxRetries = 3;
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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>
|
||||
@ -1182,7 +1370,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 +1382,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, archivedKvp.Key, "Posts",
|
||||
progressReporter, "/Archived/Posts/Free/Videos", filenameFormat,
|
||||
postInfo, mediaInfo, postInfo?.Author, users);
|
||||
postInfo, mediaInfo, postInfo?.Author, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -1276,7 +1465,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 +1477,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, messageKvp.Key, "Messages",
|
||||
progressReporter, messagePath + "/Videos", filenameFormat,
|
||||
messageInfo, mediaInfo, messageInfo?.FromUser, users);
|
||||
messageInfo, mediaInfo, messageInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -1372,7 +1562,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], "message", clientIdBlobMissing, devicePrivateKeyMissing);
|
||||
if (drmInfo == null)
|
||||
@ -1383,7 +1574,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, kvpEntry.Key, "Messages",
|
||||
progressReporter, paidMsgPath + "/Videos", filenameFormat,
|
||||
messageInfo, mediaInfo, messageInfo?.FromUser, users);
|
||||
messageInfo, mediaInfo, messageInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -1464,7 +1655,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 +1667,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, kvpEntry.Key, "Streams",
|
||||
progressReporter, streamPath + "/Videos", filenameFormat,
|
||||
streamInfo, mediaInfo, streamInfo?.Author, users);
|
||||
streamInfo, mediaInfo, streamInfo?.Author, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -1556,7 +1748,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 +1760,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, postKvp.Key, "Posts",
|
||||
progressReporter, postPath + "/Videos", filenameFormat,
|
||||
postInfo, mediaInfo, postInfo?.Author, users);
|
||||
postInfo, mediaInfo, postInfo?.Author, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -1649,7 +1842,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)
|
||||
@ -1660,7 +1854,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, postKvp.Key, "Posts",
|
||||
progressReporter, paidPostPath + "/Videos", filenameFormat,
|
||||
postInfo, mediaInfo, postInfo?.FromUser, users);
|
||||
postInfo, mediaInfo, postInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -1733,7 +1927,7 @@ public class DownloadService(
|
||||
if (purchasedPostKvp.Value.Contains("cdn3.onlyfans.com/dash/files"))
|
||||
{
|
||||
string[] parsed = purchasedPostKvp.Value.Split(',');
|
||||
(string decryptionKey, DateTime lastModified)? drmInfo =
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo =
|
||||
await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3],
|
||||
parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing);
|
||||
if (drmInfo == null)
|
||||
@ -1744,7 +1938,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, purchasedPostKvp.Key,
|
||||
"Posts", progressReporter, paidPostPath + "/Videos", filenameFormat,
|
||||
postInfo, mediaInfo, postInfo?.FromUser, users);
|
||||
postInfo, mediaInfo, postInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -1817,7 +2011,7 @@ public class DownloadService(
|
||||
if (paidMessageKvp.Value.Contains("cdn3.onlyfans.com/dash/files"))
|
||||
{
|
||||
string[] parsed = paidMessageKvp.Value.Split(',');
|
||||
(string decryptionKey, DateTime lastModified)? drmInfo =
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo =
|
||||
await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3],
|
||||
parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing);
|
||||
if (drmInfo == null)
|
||||
@ -1828,7 +2022,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, paidMessageKvp.Key,
|
||||
"Messages", progressReporter, paidMsgPath + "/Videos", filenameFormat,
|
||||
messageInfo, mediaInfo, messageInfo?.FromUser, users);
|
||||
messageInfo, mediaInfo, messageInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -1899,7 +2093,7 @@ public class DownloadService(
|
||||
if (postKvp.Value.Contains("cdn3.onlyfans.com/dash/files"))
|
||||
{
|
||||
string[] parsed = postKvp.Value.Split(',');
|
||||
(string decryptionKey, DateTime lastModified)? drmInfo =
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo =
|
||||
await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3],
|
||||
parsed[4], parsed[5], "post", clientIdBlobMissing, devicePrivateKeyMissing);
|
||||
if (drmInfo == null)
|
||||
@ -1910,7 +2104,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, postKvp.Key, "Posts",
|
||||
progressReporter, postPath + "/Videos", filenameFormat,
|
||||
postInfo, mediaInfo, postInfo?.Author, users);
|
||||
postInfo, mediaInfo, postInfo?.Author, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -1989,7 +2183,7 @@ public class DownloadService(
|
||||
if (paidMessageKvp.Value.Contains("cdn3.onlyfans.com/dash/files"))
|
||||
{
|
||||
string[] parsed = paidMessageKvp.Value.Split(',');
|
||||
(string decryptionKey, DateTime lastModified)? drmInfo =
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo =
|
||||
await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3],
|
||||
parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing);
|
||||
if (drmInfo == null)
|
||||
@ -2000,7 +2194,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, paidMessageKvp.Key,
|
||||
"Messages", progressReporter, previewMsgPath + "/Videos", filenameFormat,
|
||||
messageInfo, mediaInfo, messageInfo?.FromUser, users);
|
||||
messageInfo, mediaInfo, messageInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -2043,7 +2237,7 @@ public class DownloadService(
|
||||
if (paidMessageKvp.Value.Contains("cdn3.onlyfans.com/dash/files"))
|
||||
{
|
||||
string[] parsed = paidMessageKvp.Value.Split(',');
|
||||
(string decryptionKey, DateTime lastModified)? drmInfo =
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? drmInfo =
|
||||
await GetDecryptionInfo(parsed[0], parsed[1], parsed[2], parsed[3],
|
||||
parsed[4], parsed[5], "message", clientIdBlobMissing, devicePrivateKeyMissing);
|
||||
if (drmInfo == null)
|
||||
@ -2054,7 +2248,7 @@ public class DownloadService(
|
||||
isNew = await DownloadDrmVideo(parsed[1], parsed[2], parsed[3], parsed[0],
|
||||
drmInfo.Value.decryptionKey, path, drmInfo.Value.lastModified, paidMessageKvp.Key,
|
||||
"Messages", progressReporter, singlePaidMsgPath + "/Videos", filenameFormat,
|
||||
messageInfo, mediaInfo, messageInfo?.FromUser, users);
|
||||
messageInfo, mediaInfo, messageInfo?.FromUser, users, drmInfo.Value.mpdDurationSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using System.Reflection;
|
||||
using OF_DL.Models.Config;
|
||||
using OF_DL.Models.Downloads;
|
||||
using OF_DL.Services;
|
||||
@ -77,7 +78,7 @@ public class DownloadServiceTests
|
||||
DownloadService service =
|
||||
CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService);
|
||||
|
||||
(string decryptionKey, DateTime lastModified)? result = await service.GetDecryptionInfo(
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo(
|
||||
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
|
||||
true, false);
|
||||
|
||||
@ -95,7 +96,7 @@ public class DownloadServiceTests
|
||||
DownloadService service =
|
||||
CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService);
|
||||
|
||||
(string decryptionKey, DateTime lastModified)? result = await service.GetDecryptionInfo(
|
||||
(string decryptionKey, DateTime lastModified, double? mpdDurationSeconds)? result = await service.GetDecryptionInfo(
|
||||
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
|
||||
false, false);
|
||||
|
||||
@ -106,6 +107,38 @@ public class DownloadServiceTests
|
||||
Assert.False(apiService.OfdlCalled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FinalizeDrmDownload_DoesNotDeleteFile_WhenCustomPathMatchesTempPath()
|
||||
{
|
||||
using TempFolder temp = new();
|
||||
string folder = NormalizeFolder(Path.Combine(temp.Path, "creator"));
|
||||
string path = "/Posts/Free";
|
||||
string filename = "video";
|
||||
string customFileName = "video_source";
|
||||
string tempFilename = $"{folder}{path}/{filename}_source.mp4";
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(tempFilename) ?? throw new InvalidOperationException());
|
||||
await File.WriteAllTextAsync(tempFilename, "abc");
|
||||
|
||||
MediaTrackingDbService dbService = new();
|
||||
DownloadService service = CreateService(new FakeConfigService(new Config { ShowScrapeSize = false }), dbService);
|
||||
ProgressRecorder progress = new();
|
||||
|
||||
MethodInfo? finalizeMethod = typeof(DownloadService).GetMethod("FinalizeDrmDownload",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(finalizeMethod);
|
||||
|
||||
object? resultObject = finalizeMethod.Invoke(service,
|
||||
[
|
||||
tempFilename, DateTime.UtcNow, folder, path, customFileName, filename, 1L, "Posts", progress
|
||||
]);
|
||||
|
||||
bool result = await Assert.IsType<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()
|
||||
{
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -172,6 +172,23 @@ public class Program(IServiceProvider serviceProvider)
|
||||
Environment.Exit(4);
|
||||
}
|
||||
|
||||
if (!startupResult.FfprobeFound)
|
||||
{
|
||||
if (!configService.CurrentConfig.NonInteractiveMode)
|
||||
{
|
||||
AnsiConsole.Markup(
|
||||
"[red]Cannot locate FFprobe; please modify config.conf with the correct path. Press any key to exit.[/]");
|
||||
Console.ReadKey();
|
||||
}
|
||||
else
|
||||
{
|
||||
AnsiConsole.Markup(
|
||||
"[red]Cannot locate FFprobe; please modify config.conf with the correct path.[/]");
|
||||
}
|
||||
|
||||
Environment.Exit(4);
|
||||
}
|
||||
|
||||
// Auth flow
|
||||
await HandleAuthFlow(authService, configService);
|
||||
|
||||
@ -840,25 +857,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
|
||||
|
||||
@ -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.
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -4,7 +4,7 @@ Once you are happy you have filled everything in [auth.json](/config/auth) corre
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user