mirror of
https://github.com/Crunchy-DL/Crunchy-Downloader.git
synced 2026-01-11 20:10:26 +00:00
Add - Added **hardware acceleration options** for sync timings Add - Added an **icon for episodes removed from Crunchyroll** or moved to a different season Add - Added **highlighting for selected dubs/subs** in history Add - Added **highlighting of episode titles** when all selected dubs/subs are available Add - Added option to **set history series to inactive** Add - Added **filter for Active and Inactive** series in history Add - Added **download retry options** to settings, making retry variables editable Chg - Changed **Sonarr missing filter** to respect the "Skip Unmonitored" setting Chg - Changed to **show an error** if an episode wasn't added to the queue Chg - Changed to show **dates for episodes** in the history Chg - Changed **sync timings algorithm** to improve overall performance Chg - Changed **sync timings algorithm** to more accurately synchronize dubs Chg - Changed **series/season refresh messages** to indicate failure or success more clearly Chg - Changed **error display frequency** to reduce repeated popups for the same message Fix - Fixed **movie detection** to properly verify if the ID corresponds to a movie Fix - Fixed **long option hiding remove buttons** for FFmpeg and MKVMerge additional options Fix - Fixed **toast message timer** not updating correctly Fix - Fixed **path length error** Fix - Fixed a **rare crash when navigating history**
858 lines
No EOL
32 KiB
C#
858 lines
No EOL
32 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Runtime.InteropServices;
|
|
using System.Runtime.Serialization;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading.Tasks;
|
|
using Avalonia;
|
|
using Avalonia.Controls;
|
|
using Avalonia.Controls.ApplicationLifetimes;
|
|
using Avalonia.Media;
|
|
using Avalonia.Media.Imaging;
|
|
using CRD.Downloader;
|
|
using CRD.Utils.Ffmpeg_Encoding;
|
|
using CRD.Utils.Files;
|
|
using CRD.Utils.JsonConv;
|
|
using CRD.Utils.Structs;
|
|
using CRD.Utils.Structs.Crunchyroll;
|
|
using Microsoft.Win32;
|
|
using Newtonsoft.Json;
|
|
using Newtonsoft.Json.Serialization;
|
|
|
|
namespace CRD.Utils;
|
|
|
|
public class Helpers{
|
|
public static T? Deserialize<T>(string json, JsonSerializerSettings? serializerSettings){
|
|
try{
|
|
serializerSettings ??= new JsonSerializerSettings();
|
|
serializerSettings.Converters.Add(new UtcToLocalTimeConverter());
|
|
|
|
return JsonConvert.DeserializeObject<T>(json, serializerSettings);
|
|
} catch (JsonException ex){
|
|
Console.Error.WriteLine($"Error deserializing JSON: {ex.Message}");
|
|
}
|
|
|
|
return default;
|
|
}
|
|
|
|
public static T DeepCopy<T>(T obj){
|
|
var settings = new JsonSerializerSettings{
|
|
ContractResolver = new DefaultContractResolver{
|
|
IgnoreSerializableAttribute = true,
|
|
IgnoreSerializableInterface = true
|
|
},
|
|
ObjectCreationHandling = ObjectCreationHandling.Replace
|
|
};
|
|
|
|
var json = JsonConvert.SerializeObject(obj, settings);
|
|
return JsonConvert.DeserializeObject<T>(json);
|
|
}
|
|
|
|
|
|
public static string ConvertTimeFormat(string time){
|
|
var timeParts = time.Split(':', '.');
|
|
int hours = int.Parse(timeParts[0]);
|
|
int minutes = int.Parse(timeParts[1]);
|
|
int seconds = int.Parse(timeParts[2]);
|
|
int milliseconds = int.Parse(timeParts[3]);
|
|
|
|
return $"{hours}:{minutes:D2}:{seconds:D2}.{milliseconds / 10:D2}";
|
|
}
|
|
|
|
public static string ConvertVTTStylesToASS(string dialogue){
|
|
dialogue = Regex.Replace(dialogue, @"<b>", "{\\b1}");
|
|
dialogue = Regex.Replace(dialogue, @"</b>", "{\\b0}");
|
|
dialogue = Regex.Replace(dialogue, @"<i>", "{\\i1}");
|
|
dialogue = Regex.Replace(dialogue, @"</i>", "{\\i0}");
|
|
dialogue = Regex.Replace(dialogue, @"<u>", "{\\u1}");
|
|
dialogue = Regex.Replace(dialogue, @"</u>", "{\\u0}");
|
|
|
|
dialogue = Regex.Replace(dialogue, @"<[^>]+>", ""); // Remove any other HTML-like tags
|
|
|
|
return dialogue;
|
|
}
|
|
|
|
public static void OpenUrl(string url){
|
|
try{
|
|
Process.Start(new ProcessStartInfo{
|
|
FileName = url,
|
|
UseShellExecute = true
|
|
});
|
|
} catch (Exception e){
|
|
Console.Error.WriteLine($"An error occurred while trying to open URL - {url} : {e.Message}");
|
|
}
|
|
}
|
|
|
|
public static void EnsureDirectoriesExist(string path){
|
|
// Console.WriteLine($"Check if path exists: {path}");
|
|
|
|
// Check if the path is absolute
|
|
bool isAbsolute = Path.IsPathRooted(path);
|
|
|
|
// Get all directory parts of the path except the last segment (assuming it's a file)
|
|
string directoryPath = Path.GetDirectoryName(path);
|
|
|
|
if (string.IsNullOrEmpty(directoryPath)){
|
|
Console.WriteLine("The provided path does not contain any directory information.");
|
|
return;
|
|
}
|
|
|
|
// Initialize the cumulative path based on whether the original path is absolute or not
|
|
string cumulativePath = isAbsolute ? Path.GetPathRoot(directoryPath) : Environment.CurrentDirectory;
|
|
|
|
// Get all directory parts
|
|
string[] directories = directoryPath.Split(Path.DirectorySeparatorChar);
|
|
|
|
// Start the loop from the correct initial index
|
|
int startIndex = isAbsolute && directories.Length > 0 && string.IsNullOrEmpty(directories[0]) ? 1 : 0;
|
|
|
|
if (isAbsolute && cumulativePath == "/"){
|
|
cumulativePath = "/";
|
|
}
|
|
|
|
for (int i = startIndex; i < directories.Length; i++){
|
|
// Skip empty parts
|
|
if (string.IsNullOrEmpty(directories[i])){
|
|
continue;
|
|
}
|
|
|
|
// Build the path incrementally
|
|
cumulativePath = Path.Combine(cumulativePath, directories[i]);
|
|
|
|
// Check if the directory exists and create it if it does not
|
|
if (!Directory.Exists(cumulativePath)){
|
|
Directory.CreateDirectory(cumulativePath);
|
|
Console.WriteLine($"Created directory: {cumulativePath}");
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
public static bool IsValidPath(string path){
|
|
char[] invalidChars = Path.GetInvalidPathChars();
|
|
|
|
if (string.IsNullOrWhiteSpace(path)){
|
|
return false;
|
|
}
|
|
|
|
if (path.Any(ch => invalidChars.Contains(ch))){
|
|
return false;
|
|
}
|
|
|
|
try{
|
|
// Use Path.GetFullPath to ensure that the path can be fully qualified
|
|
string fullPath = Path.GetFullPath(path);
|
|
return true;
|
|
} catch (Exception){
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public static Locale ConvertStringToLocale(string? value){
|
|
foreach (Locale locale in Enum.GetValues(typeof(Locale))){
|
|
var type = typeof(Locale);
|
|
var memInfo = type.GetMember(locale.ToString());
|
|
var attributes = memInfo[0].GetCustomAttributes(typeof(EnumMemberAttribute), false);
|
|
var description = ((EnumMemberAttribute)attributes[0]).Value;
|
|
|
|
if (description == value){
|
|
return locale;
|
|
}
|
|
}
|
|
|
|
if (string.IsNullOrEmpty(value)){
|
|
return Locale.DefaulT;
|
|
}
|
|
|
|
return Locale.Unknown; // Return default if not found
|
|
}
|
|
|
|
public static string GenerateSessionId(){
|
|
// Get UTC milliseconds
|
|
var utcNow = DateTime.UtcNow;
|
|
var milliseconds = utcNow.Millisecond.ToString().PadLeft(3, '0');
|
|
|
|
// Get a high-resolution timestamp
|
|
long timestamp = Stopwatch.GetTimestamp();
|
|
double timestampToMilliseconds = (double)timestamp / Stopwatch.Frequency * 1000;
|
|
string highResTimestamp = timestampToMilliseconds.ToString("F0").PadLeft(13, '0');
|
|
|
|
return milliseconds + highResTimestamp;
|
|
}
|
|
|
|
public static void ConvertChapterFileForFFMPEG(string chapterFilePath){
|
|
var chapterLines = File.ReadAllLines(chapterFilePath);
|
|
var ffmpegChapterLines = new List<string>{ ";FFMETADATA1" };
|
|
var chapters = new List<(double StartTime, string Title)>();
|
|
|
|
for (int i = 0; i < chapterLines.Length; i += 2){
|
|
var timeLine = chapterLines[i];
|
|
var nameLine = chapterLines[i + 1];
|
|
|
|
var timeParts = timeLine.Split('=');
|
|
var nameParts = nameLine.Split('=');
|
|
|
|
if (timeParts.Length == 2 && nameParts.Length == 2){
|
|
var startTime = TimeSpan.Parse(timeParts[1]).TotalMilliseconds;
|
|
var title = nameParts[1];
|
|
chapters.Add((startTime, title));
|
|
}
|
|
}
|
|
|
|
// Sort chapters by start time
|
|
chapters = chapters.OrderBy(c => c.StartTime).ToList();
|
|
|
|
for (int i = 0; i < chapters.Count; i++){
|
|
var startTime = chapters[i].StartTime;
|
|
var title = chapters[i].Title;
|
|
var endTime = (i + 1 < chapters.Count) ? chapters[i + 1].StartTime : startTime + 10000; // Add 10 seconds to the last chapter end time
|
|
|
|
if (endTime < startTime){
|
|
endTime = startTime + 10000; // Correct end time if it is before start time
|
|
}
|
|
|
|
ffmpegChapterLines.Add("[CHAPTER]");
|
|
ffmpegChapterLines.Add("TIMEBASE=1/1000");
|
|
ffmpegChapterLines.Add($"START={startTime}");
|
|
ffmpegChapterLines.Add($"END={endTime}");
|
|
ffmpegChapterLines.Add($"title={title}");
|
|
}
|
|
|
|
File.WriteAllLines(chapterFilePath, ffmpegChapterLines);
|
|
}
|
|
|
|
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string type, string bin, string command){
|
|
try{
|
|
using (var process = new Process()){
|
|
process.StartInfo.FileName = bin;
|
|
process.StartInfo.Arguments = command;
|
|
process.StartInfo.RedirectStandardOutput = true;
|
|
process.StartInfo.RedirectStandardError = true;
|
|
process.StartInfo.UseShellExecute = false;
|
|
process.StartInfo.CreateNoWindow = true;
|
|
|
|
process.OutputDataReceived += (sender, e) => {
|
|
if (!string.IsNullOrEmpty(e.Data)){
|
|
if (e.Data.StartsWith("Error:")){
|
|
Console.Error.WriteLine(e.Data);
|
|
} else{
|
|
Console.WriteLine(e.Data);
|
|
}
|
|
}
|
|
};
|
|
|
|
process.ErrorDataReceived += (sender, e) => {
|
|
if (!string.IsNullOrEmpty(e.Data)){
|
|
Console.Error.WriteLine($"{e.Data}");
|
|
}
|
|
};
|
|
|
|
process.Start();
|
|
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
await process.WaitForExitAsync();
|
|
|
|
bool isSuccess = process.ExitCode == 0;
|
|
|
|
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
|
|
}
|
|
} catch (Exception ex){
|
|
Console.Error.WriteLine($"An error occurred: {ex.Message}");
|
|
return (IsOk: false, ErrorCode: -1);
|
|
}
|
|
}
|
|
|
|
public static void DeleteFile(string filePath){
|
|
if (string.IsNullOrEmpty(filePath)){
|
|
return;
|
|
}
|
|
|
|
try{
|
|
if (File.Exists(filePath)){
|
|
File.Delete(filePath);
|
|
}
|
|
} catch (Exception ex){
|
|
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsyncWorkDir(string type, string bin, string command, string workingDir){
|
|
try{
|
|
using (var process = new Process()){
|
|
process.StartInfo.WorkingDirectory = workingDir;
|
|
process.StartInfo.FileName = bin;
|
|
process.StartInfo.Arguments = command;
|
|
process.StartInfo.RedirectStandardOutput = true;
|
|
process.StartInfo.RedirectStandardError = true;
|
|
process.StartInfo.UseShellExecute = false;
|
|
process.StartInfo.CreateNoWindow = true;
|
|
|
|
process.OutputDataReceived += (sender, e) => {
|
|
if (!string.IsNullOrEmpty(e.Data)){
|
|
Console.WriteLine(e.Data);
|
|
}
|
|
};
|
|
|
|
process.ErrorDataReceived += (sender, e) => {
|
|
if (!string.IsNullOrEmpty(e.Data)){
|
|
Console.Error.WriteLine($"{e.Data}");
|
|
}
|
|
};
|
|
|
|
process.Start();
|
|
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
await process.WaitForExitAsync();
|
|
|
|
bool isSuccess = process.ExitCode == 0;
|
|
|
|
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
|
|
}
|
|
} catch (Exception ex){
|
|
Console.Error.WriteLine($"An error occurred: {ex.Message}");
|
|
return (IsOk: false, ErrorCode: -1);
|
|
}
|
|
}
|
|
|
|
private static string GetQualityOption(VideoPreset preset){
|
|
return preset.Codec switch{
|
|
"h264_nvenc" or "hevc_nvenc" => $"-cq {preset.Crf}", // For NVENC
|
|
"h264_qsv" or "hevc_qsv" => $"-global_quality {preset.Crf}", // For Intel QSV
|
|
"h264_amf" or "hevc_amf" => $"-qp {preset.Crf}", // For AMD VCE
|
|
_ => $"-crf {preset.Crf}", // For software codecs like libx264/libx265
|
|
};
|
|
}
|
|
|
|
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(string inputFilePath, VideoPreset preset, CrunchyEpMeta? data = null){
|
|
try{
|
|
string outputExtension = Path.GetExtension(inputFilePath);
|
|
string directory = Path.GetDirectoryName(inputFilePath);
|
|
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(inputFilePath);
|
|
string tempOutputFilePath = Path.Combine(directory, $"{fileNameWithoutExtension}_output{outputExtension}");
|
|
|
|
string additionalParams = string.Join(" ", preset.AdditionalParameters.Select(param => {
|
|
var splitIndex = param.IndexOf(' ');
|
|
if (splitIndex > 0){
|
|
var prefix = param[..splitIndex];
|
|
var value = param[(splitIndex + 1)..];
|
|
|
|
if (value.Contains(' ') && !(value.StartsWith("\"") && value.EndsWith("\""))){
|
|
value = $"\"{value}\"";
|
|
}
|
|
|
|
return $"{prefix} {value}";
|
|
}
|
|
|
|
return param;
|
|
}));
|
|
|
|
string qualityOption = GetQualityOption(preset);
|
|
|
|
TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
|
|
if (totalDuration == null){
|
|
Console.Error.WriteLine("Unable to retrieve input file duration.");
|
|
} else{
|
|
Console.WriteLine($"Total Duration: {totalDuration}");
|
|
}
|
|
|
|
|
|
string ffmpegCommand = $"-loglevel info -i \"{inputFilePath}\" -c:v {preset.Codec} {qualityOption} -vf \"scale={preset.Resolution},fps={preset.FrameRate}\" {additionalParams} \"{tempOutputFilePath}\"";
|
|
using (var process = new Process()){
|
|
process.StartInfo.FileName = CfgManager.PathFFMPEG;
|
|
process.StartInfo.Arguments = ffmpegCommand;
|
|
process.StartInfo.RedirectStandardOutput = true;
|
|
process.StartInfo.RedirectStandardError = true;
|
|
process.StartInfo.UseShellExecute = false;
|
|
process.StartInfo.CreateNoWindow = true;
|
|
|
|
process.OutputDataReceived += (sender, e) => {
|
|
if (!string.IsNullOrEmpty(e.Data)){
|
|
Console.WriteLine(e.Data);
|
|
}
|
|
};
|
|
|
|
process.ErrorDataReceived += (sender, e) => {
|
|
if (!string.IsNullOrEmpty(e.Data)){
|
|
Console.Error.WriteLine($"{e.Data}");
|
|
if (data != null && totalDuration != null){
|
|
ParseProgress(e.Data, totalDuration.Value, data);
|
|
}
|
|
}
|
|
};
|
|
|
|
process.Start();
|
|
process.BeginOutputReadLine();
|
|
process.BeginErrorReadLine();
|
|
|
|
await process.WaitForExitAsync();
|
|
|
|
bool isSuccess = process.ExitCode == 0;
|
|
|
|
if (isSuccess){
|
|
// Delete the original input file
|
|
File.Delete(inputFilePath);
|
|
|
|
// Rename the output file to the original name
|
|
File.Move(tempOutputFilePath, inputFilePath);
|
|
} else{
|
|
// If something went wrong, delete the temporary output file
|
|
File.Delete(tempOutputFilePath);
|
|
Console.Error.WriteLine("FFmpeg processing failed.");
|
|
Console.Error.WriteLine($"Command: {ffmpegCommand}");
|
|
}
|
|
|
|
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
|
|
}
|
|
} catch (Exception ex){
|
|
Console.Error.WriteLine($"An error occurred: {ex.Message}");
|
|
return (IsOk: false, ErrorCode: -1);
|
|
}
|
|
}
|
|
|
|
private static void ParseProgress(string progressString, TimeSpan totalDuration, CrunchyEpMeta data){
|
|
try{
|
|
if (progressString.Contains("time=")){
|
|
var timeIndex = progressString.IndexOf("time=") + 5;
|
|
var timeString = progressString.Substring(timeIndex, 11);
|
|
|
|
|
|
if (TimeSpan.TryParse(timeString, out var currentTime)){
|
|
int progress = (int)(currentTime.TotalSeconds / totalDuration.TotalSeconds * 100);
|
|
Console.WriteLine($"Progress: {progress:F2}%");
|
|
|
|
data.DownloadProgress = new DownloadProgress(){
|
|
IsDownloading = true,
|
|
Percent = progress,
|
|
Time = 0,
|
|
DownloadSpeed = 0,
|
|
Doing = "Encoding"
|
|
};
|
|
|
|
QueueManager.Instance.Queue.Refresh();
|
|
}
|
|
}
|
|
} catch (Exception e){
|
|
Console.Error.WriteLine("Failed to calculate encoding progess");
|
|
Console.Error.WriteLine(e.Message);
|
|
}
|
|
}
|
|
|
|
public static async Task<TimeSpan?> GetMediaDurationAsync(string ffmpegPath, string inputFilePath){
|
|
try{
|
|
using (var process = new Process()){
|
|
process.StartInfo.FileName = ffmpegPath;
|
|
process.StartInfo.Arguments = $"-i \"{inputFilePath}\"";
|
|
process.StartInfo.UseShellExecute = false;
|
|
process.StartInfo.RedirectStandardError = true;
|
|
process.StartInfo.CreateNoWindow = true;
|
|
|
|
string output = string.Empty;
|
|
process.ErrorDataReceived += (sender, e) => {
|
|
if (!string.IsNullOrEmpty(e.Data)){
|
|
output += e.Data + Environment.NewLine;
|
|
}
|
|
};
|
|
|
|
process.Start();
|
|
process.BeginErrorReadLine();
|
|
|
|
await process.WaitForExitAsync();
|
|
|
|
Regex regex = new Regex(@"Duration: (\d{2}):(\d{2}):(\d{2}\.\d{2})");
|
|
Match match = regex.Match(output);
|
|
if (match.Success){
|
|
int hours = int.Parse(match.Groups[1].Value);
|
|
int minutes = int.Parse(match.Groups[2].Value);
|
|
double seconds = double.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture);
|
|
|
|
return new TimeSpan(hours, minutes, (int)seconds);
|
|
}
|
|
}
|
|
} catch (Exception ex){
|
|
Console.Error.WriteLine($"An error occurred while retrieving media duration: {ex.Message}");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static double CalculateCosineSimilarity(string text1, string text2){
|
|
var vector1 = ComputeWordFrequency(text1);
|
|
var vector2 = ComputeWordFrequency(text2);
|
|
|
|
return CosineSimilarity(vector1, vector2);
|
|
}
|
|
|
|
private static readonly char[] Delimiters ={ ' ', ',', '.', ';', ':', '-', '_', '\'' };
|
|
|
|
public static Dictionary<string, double> ComputeWordFrequency(string text){
|
|
var wordFrequency = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
|
|
var words = SplitText(text);
|
|
|
|
foreach (var word in words){
|
|
if (wordFrequency.TryGetValue(word, out double count)){
|
|
wordFrequency[word] = count + 1;
|
|
} else{
|
|
wordFrequency[word] = 1;
|
|
}
|
|
}
|
|
|
|
return wordFrequency;
|
|
}
|
|
|
|
private static List<string> SplitText(string text){
|
|
var words = new List<string>();
|
|
int start = 0;
|
|
for (int i = 0; i < text.Length; i++){
|
|
if (Array.IndexOf(Delimiters, text[i]) >= 0){
|
|
if (i > start){
|
|
words.Add(text.Substring(start, i - start));
|
|
}
|
|
|
|
start = i + 1;
|
|
}
|
|
}
|
|
|
|
if (start < text.Length){
|
|
words.Add(text.Substring(start));
|
|
}
|
|
|
|
return words;
|
|
}
|
|
|
|
|
|
private static double CosineSimilarity(Dictionary<string, double> vector1, Dictionary<string, double> vector2){
|
|
var intersection = vector1.Keys.Intersect(vector2.Keys);
|
|
|
|
double dotProduct = intersection.Sum(term => vector1[term] * vector2[term]);
|
|
double normA = Math.Sqrt(vector1.Values.Sum(val => val * val));
|
|
double normB = Math.Sqrt(vector2.Values.Sum(val => val * val));
|
|
|
|
if (normA == 0 || normB == 0){
|
|
// If either vector has zero length, return 0 similarity.
|
|
return 0;
|
|
}
|
|
|
|
return dotProduct / (normA * normB);
|
|
}
|
|
|
|
public static string? ExtractNumberAfterS(string input){
|
|
// Regular expression pattern to match |S followed by a number and optionally C or P followed by another number
|
|
string pattern = @"\|S(\d+)(?:C(\d+)|P(\d+))?";
|
|
Match match = Regex.Match(input, pattern);
|
|
|
|
if (match.Success){
|
|
string sNumber = match.Groups[1].Value; // Extract the S number
|
|
string cNumber = match.Groups[2].Value; // Extract the C number if present
|
|
string pNumber = match.Groups[3].Value; // Extract the P number if present
|
|
|
|
if (!string.IsNullOrEmpty(cNumber)){
|
|
// Case for C: Return S + . + C
|
|
return $"{sNumber}.{cNumber}";
|
|
} else if (!string.IsNullOrEmpty(pNumber)){
|
|
// Case for P: Increment S by P - 1
|
|
if (int.TryParse(sNumber, out int sNumeric) && int.TryParse(pNumber, out int pNumeric)){
|
|
return (sNumeric + (pNumeric - 1)).ToString();
|
|
}
|
|
} else{
|
|
// Return only S if no C or P is present
|
|
return sNumber;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
|
|
public static async Task<Bitmap?> LoadImage(string imageUrl, int desiredWidth = 0, int desiredHeight = 0){
|
|
try{
|
|
var response = await HttpClientReq.Instance.GetHttpClient().GetAsync(imageUrl);
|
|
|
|
if (ChallengeDetector.IsClearanceRequired(response)){
|
|
Console.Error.WriteLine($"Cloudflare Challenge detected ");
|
|
}
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
using (var stream = await response.Content.ReadAsStreamAsync()){
|
|
var bitmap = new Bitmap(stream);
|
|
|
|
if (desiredWidth != 0 && desiredHeight != 0){
|
|
var scaledBitmap = bitmap.CreateScaledBitmap(new PixelSize(desiredWidth, desiredHeight));
|
|
|
|
bitmap.Dispose();
|
|
|
|
return scaledBitmap;
|
|
}
|
|
|
|
|
|
return bitmap;
|
|
}
|
|
} catch (Exception ex){
|
|
Console.Error.WriteLine("Failed to load image: " + ex.Message);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public static Dictionary<string, List<DownloadedMedia>> GroupByLanguageWithSubtitles(List<DownloadedMedia> allMedia){
|
|
//Group by language
|
|
var languageGroups = allMedia
|
|
.Where(media =>
|
|
!string.IsNullOrEmpty(media.Lang.CrLocale) ||
|
|
(media.Type == DownloadMediaType.Subtitle && media.RelatedVideoDownloadMedia != null &&
|
|
!string.IsNullOrEmpty(media.RelatedVideoDownloadMedia.Lang.CrLocale))
|
|
)
|
|
.GroupBy(media => {
|
|
if (media.Type == DownloadMediaType.Subtitle && media.RelatedVideoDownloadMedia != null){
|
|
return media.RelatedVideoDownloadMedia.Lang.CrLocale;
|
|
}
|
|
|
|
return media.Lang.CrLocale;
|
|
})
|
|
.ToDictionary(group => group.Key, group => group.ToList());
|
|
|
|
//Find and add Description media to each group
|
|
var descriptionMedia = allMedia.Where(media => media.Type == DownloadMediaType.Description).ToList();
|
|
|
|
foreach (var description in descriptionMedia){
|
|
foreach (var group in languageGroups.Values){
|
|
group.Add(description);
|
|
}
|
|
}
|
|
|
|
return languageGroups;
|
|
}
|
|
|
|
public static string GetValidFolderName(string folderName){
|
|
// Get the invalid characters for a folder name
|
|
char[] invalidChars = Path.GetInvalidFileNameChars();
|
|
|
|
// Check if the folder name contains any invalid characters
|
|
bool isValid = !folderName.Any(c => invalidChars.Contains(c));
|
|
|
|
// Check for reserved names on Windows
|
|
string[] reservedNames =["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"];
|
|
bool isReservedName = reservedNames.Contains(folderName.ToUpperInvariant());
|
|
|
|
if (isValid && !isReservedName && folderName.Length <= 255){
|
|
return folderName; // Return the original folder name if it's valid
|
|
}
|
|
|
|
string uuid = Guid.NewGuid().ToString();
|
|
return uuid;
|
|
}
|
|
|
|
public static string LimitFileNameLength(string fileName, int maxFileNameLength){
|
|
string directory = Path.GetDirectoryName(fileName) ?? string.Empty;
|
|
string name = Path.GetFileNameWithoutExtension(fileName);
|
|
string extension = Path.GetExtension(fileName);
|
|
|
|
if (name.Length > maxFileNameLength - extension.Length){
|
|
name = name.Substring(0, maxFileNameLength - extension.Length);
|
|
}
|
|
|
|
return Path.Combine(directory, name + extension);
|
|
}
|
|
|
|
public static string AddUncPrefixIfNeeded(string path){
|
|
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !IsLongPathEnabled()){
|
|
if (!string.IsNullOrEmpty(path) && !path.StartsWith(@"\\?\")){
|
|
return $@"\\?\{Path.GetFullPath(path)}";
|
|
}
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
|
|
private static bool IsLongPathEnabled(){
|
|
try{
|
|
using (var key = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\FileSystem")){
|
|
if (key != null){
|
|
var value = key.GetValue("LongPathsEnabled", 0);
|
|
return value is int intValue && intValue == 1;
|
|
}
|
|
}
|
|
} catch (Exception ex){
|
|
Console.Error.WriteLine($"Failed to check if long paths are enabled: {ex.Message}");
|
|
}
|
|
|
|
return false; // Default to false if unable to read the registry
|
|
}
|
|
|
|
|
|
private static Avalonia.Controls.Image? _backgroundImageLayer;
|
|
|
|
public static void SetBackgroundImage(string backgroundImagePath, double? imageOpacity = 0.5, double? blurRadius = 10){
|
|
try{
|
|
var activeWindow = GetActiveWindow();
|
|
if (activeWindow == null)
|
|
return;
|
|
|
|
|
|
if (activeWindow.Content is not Panel rootPanel){
|
|
rootPanel = new Grid();
|
|
activeWindow.Content = rootPanel;
|
|
}
|
|
|
|
|
|
if (string.IsNullOrEmpty(backgroundImagePath)){
|
|
if (_backgroundImageLayer != null){
|
|
rootPanel.Children.Remove(_backgroundImageLayer);
|
|
_backgroundImageLayer = null;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_backgroundImageLayer == null){
|
|
_backgroundImageLayer = new Avalonia.Controls.Image{
|
|
Stretch = Stretch.UniformToFill,
|
|
ZIndex = -1,
|
|
};
|
|
rootPanel.Children.Add(_backgroundImageLayer);
|
|
}
|
|
|
|
_backgroundImageLayer.Source = new Bitmap(backgroundImagePath);
|
|
_backgroundImageLayer.Opacity = imageOpacity ?? 0.5;
|
|
|
|
_backgroundImageLayer.Effect = new BlurEffect{
|
|
Radius = blurRadius ?? 10
|
|
};
|
|
} catch (Exception ex){
|
|
Console.WriteLine($"Failed to set background image: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static Window? GetActiveWindow(){
|
|
// Ensure the application is running with a Classic Desktop Lifetime
|
|
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktopLifetime){
|
|
// Return the first active window found in the desktop application's window list
|
|
return desktopLifetime.Windows.FirstOrDefault(window => window.IsActive);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
|
|
public static bool IsInstalled(string checkFor, string versionString){
|
|
try{
|
|
// Create a new process for mkvmerge
|
|
Process process = new Process();
|
|
process.StartInfo.FileName = checkFor;
|
|
process.StartInfo.Arguments = versionString; // A harmless command to check if mkvmerge is available
|
|
process.StartInfo.RedirectStandardOutput = true;
|
|
process.StartInfo.RedirectStandardError = true;
|
|
process.StartInfo.UseShellExecute = false;
|
|
process.StartInfo.CreateNoWindow = true;
|
|
|
|
// Start the process and wait for it to exit
|
|
process.Start();
|
|
process.WaitForExit();
|
|
|
|
// If the exit code is 0, mkvmerge was found and executed successfully
|
|
return process.ExitCode == 0;
|
|
} catch (Exception){
|
|
// If an exception is caught, mkvmerge is not installed or accessible
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public static CrDownloadOptions MigrateSettings(CrDownloadOptionsYaml yaml){
|
|
if (yaml == null){
|
|
throw new ArgumentNullException(nameof(yaml));
|
|
}
|
|
|
|
return new CrDownloadOptions{
|
|
// General Settings
|
|
AutoDownload = yaml.AutoDownload,
|
|
RemoveFinishedDownload = yaml.RemoveFinishedDownload,
|
|
Timeout = yaml.Timeout,
|
|
RetryDelay = yaml.FsRetryTime,
|
|
Force = yaml.Force,
|
|
SimultaneousDownloads = yaml.SimultaneousDownloads,
|
|
Theme = yaml.Theme,
|
|
AccentColor = yaml.AccentColor,
|
|
BackgroundImagePath = yaml.BackgroundImagePath,
|
|
BackgroundImageOpacity = yaml.BackgroundImageOpacity,
|
|
BackgroundImageBlurRadius = yaml.BackgroundImageBlurRadius,
|
|
Override = yaml.Override,
|
|
CcTag = yaml.CcTag,
|
|
Nocleanup = yaml.Nocleanup,
|
|
History = yaml.History,
|
|
HistoryIncludeCrArtists = yaml.HistoryIncludeCrArtists,
|
|
HistoryLang = yaml.HistoryLang,
|
|
HistoryAddSpecials = yaml.HistoryAddSpecials,
|
|
HistorySkipUnmonitored = yaml.HistorySkipUnmonitored,
|
|
HistoryCountSonarr = yaml.HistoryCountSonarr,
|
|
SonarrProperties = yaml.SonarrProperties,
|
|
LogMode = yaml.LogMode,
|
|
DownloadDirPath = yaml.DownloadDirPath,
|
|
DownloadTempDirPath = yaml.DownloadTempDirPath,
|
|
DownloadToTempFolder = yaml.DownloadToTempFolder,
|
|
HistoryPageProperties = yaml.HistoryPageProperties,
|
|
SeasonsPageProperties = yaml.SeasonsPageProperties,
|
|
DownloadSpeedLimit = yaml.DownloadSpeedLimit,
|
|
ProxyEnabled = yaml.ProxyEnabled,
|
|
ProxySocks = yaml.ProxySocks,
|
|
ProxyHost = yaml.ProxyHost,
|
|
ProxyPort = yaml.ProxyPort,
|
|
ProxyUsername = yaml.ProxyUsername,
|
|
ProxyPassword = yaml.ProxyPassword,
|
|
|
|
// Crunchyroll Settings
|
|
Hslang = yaml.Hslang,
|
|
Kstream = yaml.Kstream,
|
|
Novids = yaml.Novids,
|
|
Noaudio = yaml.Noaudio,
|
|
StreamServer = yaml.StreamServer,
|
|
QualityVideo = yaml.QualityVideo,
|
|
QualityAudio = yaml.QualityAudio,
|
|
FileName = yaml.FileName,
|
|
Numbers = yaml.Numbers,
|
|
Partsize = yaml.Partsize,
|
|
DlSubs = yaml.DlSubs,
|
|
SkipSubs = yaml.SkipSubs,
|
|
SkipSubsMux = yaml.SkipSubsMux,
|
|
SubsAddScaledBorder = yaml.SubsAddScaledBorder,
|
|
IncludeSignsSubs = yaml.IncludeSignsSubs,
|
|
SignsSubsAsForced = yaml.SignsSubsAsForced,
|
|
IncludeCcSubs = yaml.IncludeCcSubs,
|
|
CcSubsFont = yaml.CcSubsFont,
|
|
CcSubsMuxingFlag = yaml.CcSubsMuxingFlag,
|
|
Mp4 = yaml.Mp4,
|
|
VideoTitle = yaml.VideoTitle,
|
|
IncludeVideoDescription = yaml.IncludeVideoDescription,
|
|
DescriptionLang = yaml.DescriptionLang,
|
|
FfmpegOptions = yaml.FfmpegOptions,
|
|
MkvmergeOptions = yaml.MkvmergeOptions,
|
|
DefaultSub = yaml.DefaultSub,
|
|
DefaultSubSigns = yaml.DefaultSubSigns,
|
|
DefaultSubForcedDisplay = yaml.DefaultSubForcedDisplay,
|
|
DefaultAudio = yaml.DefaultAudio,
|
|
DlVideoOnce = yaml.DlVideoOnce,
|
|
KeepDubsSeperate = yaml.KeepDubsSeperate,
|
|
SkipMuxing = yaml.SkipMuxing,
|
|
SyncTiming = yaml.SyncTiming,
|
|
IsEncodeEnabled = yaml.IsEncodeEnabled,
|
|
EncodingPresetName = yaml.EncodingPresetName,
|
|
Chapters = yaml.Chapters,
|
|
DubLang = yaml.DubLang,
|
|
SelectedCalendarLanguage = yaml.SelectedCalendarLanguage,
|
|
CalendarDubFilter = yaml.CalendarDubFilter,
|
|
CustomCalendar = yaml.CustomCalendar,
|
|
CalendarHideDubs = yaml.CalendarHideDubs,
|
|
CalendarFilterByAirDate = yaml.CalendarFilterByAirDate,
|
|
CalendarShowUpcomingEpisodes = yaml.CalendarShowUpcomingEpisodes,
|
|
StreamEndpoint = yaml.StreamEndpoint,
|
|
SearchFetchFeaturedMusic = yaml.SearchFetchFeaturedMusic
|
|
};
|
|
}
|
|
} |