2024-06-15 13:55:39 +00:00
|
|
|
using System;
|
2024-05-25 22:02:45 +00:00
|
|
|
using System.Collections.Generic;
|
2024-05-04 15:35:32 +00:00
|
|
|
using System.Diagnostics;
|
2024-06-19 00:16:02 +00:00
|
|
|
using System.IO;
|
2024-05-25 22:02:45 +00:00
|
|
|
using System.Linq;
|
2024-07-12 02:35:33 +00:00
|
|
|
using System.Net.Http;
|
2024-05-04 15:35:32 +00:00
|
|
|
using System.Runtime.Serialization;
|
2024-08-09 21:16:13 +00:00
|
|
|
using System.Text;
|
2024-05-26 00:27:31 +00:00
|
|
|
using System.Text.RegularExpressions;
|
2024-05-04 15:35:32 +00:00
|
|
|
using System.Threading.Tasks;
|
2024-07-12 02:35:33 +00:00
|
|
|
using Avalonia.Media.Imaging;
|
2024-08-05 03:06:40 +00:00
|
|
|
using CRD.Utils.Structs;
|
2024-08-09 21:16:13 +00:00
|
|
|
using CRD.Utils.Structs.Crunchyroll.Music;
|
2024-05-04 15:35:32 +00:00
|
|
|
using Newtonsoft.Json;
|
|
|
|
|
|
|
|
namespace CRD.Utils;
|
|
|
|
|
|
|
|
public class Helpers{
|
|
|
|
/// <summary>
|
|
|
|
/// Deserializes a JSON string into a specified .NET type.
|
|
|
|
/// </summary>
|
|
|
|
/// <typeparam name="T">The type of the object to deserialize to.</typeparam>
|
|
|
|
/// <param name="json">The JSON string to deserialize.</param>
|
|
|
|
/// <param name="serializerSettings">The settings for deserialization if null default settings will be used</param>
|
|
|
|
/// <returns>The deserialized object of type T.</returns>
|
2024-05-25 22:02:45 +00:00
|
|
|
public static T? Deserialize<T>(string json, JsonSerializerSettings? serializerSettings){
|
2024-05-04 15:35:32 +00:00
|
|
|
try{
|
2024-05-25 22:02:45 +00:00
|
|
|
return JsonConvert.DeserializeObject<T>(json, serializerSettings);
|
2024-05-04 15:35:32 +00:00
|
|
|
} catch (JsonException ex){
|
2024-06-19 00:16:02 +00:00
|
|
|
Console.Error.WriteLine($"Error deserializing JSON: {ex.Message}");
|
2024-05-04 15:35:32 +00:00
|
|
|
throw;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-08-09 21:16:13 +00:00
|
|
|
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}";
|
|
|
|
}
|
|
|
|
|
2024-08-11 19:39:23 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2024-08-09 21:16:13 +00:00
|
|
|
public static string ExtractDialogue(string[] lines, int startLine){
|
|
|
|
var dialogueBuilder = new StringBuilder();
|
|
|
|
|
|
|
|
for (int i = startLine; i < lines.Length && !string.IsNullOrWhiteSpace(lines[i]); i++){
|
|
|
|
if (!lines[i].Contains("-->") && !lines[i].StartsWith("STYLE")){
|
|
|
|
string line = lines[i].Trim();
|
|
|
|
// Remove HTML tags and keep the inner text
|
|
|
|
line = Regex.Replace(line, @"<[^>]+>", "");
|
|
|
|
dialogueBuilder.Append(line + "\\N");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Remove the last newline character
|
|
|
|
if (dialogueBuilder.Length > 0){
|
|
|
|
dialogueBuilder.Length -= 2; // Remove the last "\N"
|
|
|
|
}
|
|
|
|
|
|
|
|
return dialogueBuilder.ToString();
|
|
|
|
}
|
|
|
|
|
2024-06-26 22:04:50 +00:00
|
|
|
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}");
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-06-21 01:49:44 +00:00
|
|
|
public static void EnsureDirectoriesExist(string 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, Path.AltDirectorySeparatorChar);
|
|
|
|
|
|
|
|
// Start the loop from the correct initial index
|
|
|
|
int startIndex = isAbsolute && directories.Length > 0 && string.IsNullOrEmpty(directories[0]) ? 2 : 0;
|
|
|
|
|
|
|
|
for (int i = startIndex; i < directories.Length; i++){
|
|
|
|
// Skip empty parts (which can occur with UNC paths)
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-05-04 15:35:32 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-06-11 21:58:44 +00:00
|
|
|
if (string.IsNullOrEmpty(value)){
|
|
|
|
return Locale.DefaulT;
|
|
|
|
}
|
2024-06-19 00:16:02 +00:00
|
|
|
|
2024-06-11 21:58:44 +00:00
|
|
|
return Locale.Unknown; // Return default if not found
|
2024-05-04 15:35:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2024-06-19 10:37:06 +00:00
|
|
|
public static void ConvertChapterFileForFFMPEG(string chapterFilePath){
|
2024-06-19 00:16:02 +00:00
|
|
|
var chapterLines = File.ReadAllLines(chapterFilePath);
|
2024-06-19 10:37:06 +00:00
|
|
|
var ffmpegChapterLines = new List<string>{ ";FFMETADATA1" };
|
2024-07-30 10:12:02 +00:00
|
|
|
var chapters = new List<(double StartTime, string Title)>();
|
2024-06-19 00:16:02 +00:00
|
|
|
|
2024-06-19 10:37:06 +00:00
|
|
|
for (int i = 0; i < chapterLines.Length; i += 2){
|
2024-06-19 00:16:02 +00:00
|
|
|
var timeLine = chapterLines[i];
|
|
|
|
var nameLine = chapterLines[i + 1];
|
|
|
|
|
|
|
|
var timeParts = timeLine.Split('=');
|
|
|
|
var nameParts = nameLine.Split('=');
|
|
|
|
|
2024-06-19 10:37:06 +00:00
|
|
|
if (timeParts.Length == 2 && nameParts.Length == 2){
|
2024-06-19 00:16:02 +00:00
|
|
|
var startTime = TimeSpan.Parse(timeParts[1]).TotalMilliseconds;
|
2024-07-30 10:12:02 +00:00
|
|
|
var title = nameParts[1];
|
|
|
|
chapters.Add((startTime, title));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// Sort chapters by start time
|
|
|
|
chapters = chapters.OrderBy(c => c.StartTime).ToList();
|
2024-06-19 00:16:02 +00:00
|
|
|
|
2024-07-30 10:12:02 +00:00
|
|
|
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
|
|
|
|
|
2024-08-05 03:06:40 +00:00
|
|
|
if (endTime < startTime){
|
2024-07-30 10:12:02 +00:00
|
|
|
endTime = startTime + 10000; // Correct end time if it is before start time
|
2024-06-19 00:16:02 +00:00
|
|
|
}
|
2024-07-30 10:12:02 +00:00
|
|
|
|
|
|
|
ffmpegChapterLines.Add("[CHAPTER]");
|
|
|
|
ffmpegChapterLines.Add("TIMEBASE=1/1000");
|
|
|
|
ffmpegChapterLines.Add($"START={startTime}");
|
|
|
|
ffmpegChapterLines.Add($"END={endTime}");
|
|
|
|
ffmpegChapterLines.Add($"title={title}");
|
2024-06-19 00:16:02 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
File.WriteAllLines(chapterFilePath, ffmpegChapterLines);
|
|
|
|
}
|
|
|
|
|
2024-05-04 15:35:32 +00:00
|
|
|
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string type, string bin, string command){
|
2024-06-19 10:37:06 +00:00
|
|
|
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;
|
2024-06-15 13:55:39 +00:00
|
|
|
|
2024-06-19 10:37:06 +00:00
|
|
|
process.OutputDataReceived += (sender, e) => {
|
|
|
|
if (!string.IsNullOrEmpty(e.Data)){
|
|
|
|
Console.WriteLine(e.Data);
|
|
|
|
}
|
|
|
|
};
|
2024-06-15 10:20:54 +00:00
|
|
|
|
2024-06-19 10:37:06 +00:00
|
|
|
process.ErrorDataReceived += (sender, e) => {
|
|
|
|
if (!string.IsNullOrEmpty(e.Data)){
|
|
|
|
Console.WriteLine($"{e.Data}");
|
|
|
|
}
|
|
|
|
};
|
2024-06-15 13:55:39 +00:00
|
|
|
|
2024-06-19 10:37:06 +00:00
|
|
|
process.Start();
|
2024-05-04 15:35:32 +00:00
|
|
|
|
2024-06-19 10:37:06 +00:00
|
|
|
process.BeginOutputReadLine();
|
|
|
|
process.BeginErrorReadLine();
|
2024-06-15 13:55:39 +00:00
|
|
|
|
2024-06-19 10:37:06 +00:00
|
|
|
await process.WaitForExitAsync();
|
2024-06-15 13:55:39 +00:00
|
|
|
|
2024-06-19 10:37:06 +00:00
|
|
|
// Define success condition more appropriately based on the application
|
|
|
|
bool isSuccess = process.ExitCode == 0;
|
2024-05-04 15:35:32 +00:00
|
|
|
|
2024-06-19 10:37:06 +00:00
|
|
|
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
|
|
|
|
}
|
|
|
|
} catch (Exception ex){
|
|
|
|
Console.Error.WriteLine($"An error occurred: {ex.Message}");
|
2024-06-21 01:49:44 +00:00
|
|
|
return (IsOk: false, ErrorCode: -1);
|
2024-05-04 15:35:32 +00:00
|
|
|
}
|
|
|
|
}
|
2024-08-05 03:06:40 +00:00
|
|
|
|
2024-07-16 23:52:46 +00:00
|
|
|
public static void DeleteFile(string filePath){
|
|
|
|
if (string.IsNullOrEmpty(filePath)){
|
|
|
|
return;
|
|
|
|
}
|
2024-08-05 03:06:40 +00:00
|
|
|
|
2024-07-16 23:52:46 +00:00
|
|
|
try{
|
|
|
|
if (File.Exists(filePath)){
|
|
|
|
File.Delete(filePath);
|
|
|
|
}
|
|
|
|
} catch (Exception ex){
|
|
|
|
Console.Error.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}");
|
|
|
|
// Handle exceptions if you need to log them or throw
|
|
|
|
}
|
|
|
|
}
|
2024-08-05 03:06:40 +00:00
|
|
|
|
|
|
|
public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsyncWorkDir(string type, string bin, string command, string workingDir){
|
2024-07-12 02:35:33 +00:00
|
|
|
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.WriteLine($"{e.Data}");
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
process.Start();
|
|
|
|
|
|
|
|
process.BeginOutputReadLine();
|
|
|
|
process.BeginErrorReadLine();
|
|
|
|
|
|
|
|
await process.WaitForExitAsync();
|
|
|
|
|
|
|
|
// Define success condition more appropriately based on the application
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
2024-05-25 22:02:45 +00:00
|
|
|
|
|
|
|
public static double CalculateCosineSimilarity(string text1, string text2){
|
|
|
|
var vector1 = ComputeWordFrequency(text1);
|
|
|
|
var vector2 = ComputeWordFrequency(text2);
|
|
|
|
|
|
|
|
return CosineSimilarity(vector1, vector2);
|
|
|
|
}
|
|
|
|
|
2024-06-11 21:58:44 +00:00
|
|
|
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);
|
2024-05-25 22:02:45 +00:00
|
|
|
|
|
|
|
foreach (var word in words){
|
2024-06-11 21:58:44 +00:00
|
|
|
if (wordFrequency.TryGetValue(word, out double count)){
|
|
|
|
wordFrequency[word] = count + 1;
|
|
|
|
} else{
|
|
|
|
wordFrequency[word] = 1;
|
2024-05-25 22:02:45 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return wordFrequency;
|
|
|
|
}
|
|
|
|
|
2024-06-11 21:58:44 +00:00
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
2024-06-19 00:16:02 +00:00
|
|
|
|
2024-05-25 22:02:45 +00:00
|
|
|
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);
|
|
|
|
}
|
2024-05-26 00:27:31 +00:00
|
|
|
|
|
|
|
public static string? ExtractNumberAfterS(string input){
|
|
|
|
// Define the regular expression pattern to match |S followed by a number and optionally C followed by another number
|
|
|
|
string pattern = @"\|S(\d+)(?:C(\d+))?";
|
|
|
|
Match match = Regex.Match(input, pattern);
|
|
|
|
|
|
|
|
if (match.Success){
|
|
|
|
string sNumber = match.Groups[1].Value;
|
|
|
|
string cNumber = match.Groups[2].Value;
|
|
|
|
|
|
|
|
if (!string.IsNullOrEmpty(cNumber)){
|
|
|
|
return $"{sNumber}.{cNumber}";
|
|
|
|
} else{
|
|
|
|
return sNumber;
|
|
|
|
}
|
|
|
|
} else{
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
}
|
2024-08-05 03:06:40 +00:00
|
|
|
|
|
|
|
|
2024-07-12 02:35:33 +00:00
|
|
|
public static async Task<Bitmap?> LoadImage(string imageUrl){
|
|
|
|
try{
|
|
|
|
using (var client = new HttpClient()){
|
|
|
|
var response = await client.GetAsync(imageUrl);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
using (var stream = await response.Content.ReadAsStreamAsync()){
|
|
|
|
return new Bitmap(stream);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} catch (Exception ex){
|
|
|
|
// Handle exceptions
|
|
|
|
Console.Error.WriteLine("Failed to load image: " + ex.Message);
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
2024-08-05 03:06:40 +00:00
|
|
|
|
|
|
|
public static Dictionary<string, List<DownloadedMedia>> GroupByLanguageWithSubtitles(List<DownloadedMedia> allMedia){
|
|
|
|
var languageGroups = allMedia
|
|
|
|
.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());
|
|
|
|
|
|
|
|
return languageGroups;
|
|
|
|
}
|
2024-05-04 15:35:32 +00:00
|
|
|
}
|