585 lines
23 KiB
C#
585 lines
23 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Net.Http;
|
|
using System.Security.Cryptography;
|
|
using System.Text.RegularExpressions;
|
|
using System.Threading.Tasks;
|
|
using CRD.Downloader;
|
|
using CRD.Utils.Parser.Utils;
|
|
using CRD.Utils.Structs;
|
|
using Newtonsoft.Json;
|
|
|
|
namespace CRD.Utils.HLS;
|
|
|
|
public class HlsDownloader{
|
|
private Data _data = new();
|
|
|
|
private CrunchyEpMeta _currentEpMeta;
|
|
private bool _isVideo;
|
|
private bool _isAudio;
|
|
|
|
public HlsDownloader(HlsOptions options, CrunchyEpMeta meta, bool isVideo, bool isAudio){
|
|
if (options == null || options.M3U8Json == null || options.M3U8Json.Segments == null){
|
|
throw new Exception("Playlist is empty");
|
|
}
|
|
|
|
_currentEpMeta = meta;
|
|
|
|
_isVideo = isVideo;
|
|
_isAudio = isAudio;
|
|
|
|
if (options?.M3U8Json != null)
|
|
_data = new Data{
|
|
Parts = new PartsData{
|
|
First = options.M3U8Json.MediaSequence ?? 0,
|
|
Total = options.M3U8Json.Segments?.Count,
|
|
Completed = 0,
|
|
},
|
|
M3U8Json = options.M3U8Json,
|
|
OutputFile = options.Output ?? "stream.ts",
|
|
Threads = options.Threads ?? 5,
|
|
Retries = options.Retries ?? 4,
|
|
Offset = options.Offset ?? 0,
|
|
BaseUrl = options.BaseUrl,
|
|
SkipInit = options.SkipInit ?? false,
|
|
Timeout = options.Timeout ?? 60 * 1000,
|
|
CheckPartLength = true,
|
|
IsResume = options.Offset.HasValue && options.Offset.Value > 0,
|
|
BytesDownloaded = 0,
|
|
WaitTime = options.FsRetryTime ?? 1000 * 5,
|
|
Override = options.Override,
|
|
DateStart = 0
|
|
};
|
|
}
|
|
|
|
|
|
public async Task<(bool Ok, PartsData Parts)> Download(){
|
|
string fn = _data.OutputFile ?? string.Empty;
|
|
|
|
if (File.Exists(fn) && File.Exists($"{fn}.resume") && _data.Offset < 1){
|
|
try{
|
|
Console.WriteLine("Resume data found! Trying to resume...");
|
|
string resumeFileContent = File.ReadAllText($"{fn}.resume");
|
|
var resumeData = JsonConvert.DeserializeObject<ResumeData>(resumeFileContent);
|
|
|
|
if (resumeData != null){
|
|
if (resumeData.Total == _data.M3U8Json?.Segments.Count &&
|
|
resumeData.Completed != resumeData.Total &&
|
|
!double.IsNaN(resumeData.Completed)){
|
|
Console.WriteLine("Resume data is ok!");
|
|
_data.Offset = resumeData.Completed;
|
|
_data.IsResume = true;
|
|
} else{
|
|
Console.WriteLine("Resume data is wrong!");
|
|
Console.WriteLine($"Resume: {{ total: {resumeData.Total}, dled: {resumeData.Completed} }}, " +
|
|
$"Current: {{ total: {_data.M3U8Json?.Segments.Count} }}");
|
|
}
|
|
} else{
|
|
Console.WriteLine("Resume data is wrong!");
|
|
Console.WriteLine($"Resume: {{ total: {resumeData?.Total}, dled: {resumeData?.Completed} }}, " +
|
|
$"Current: {{ total: {_data.M3U8Json?.Segments.Count} }}");
|
|
}
|
|
} catch (Exception e){
|
|
Console.WriteLine("Resume failed, downloading will not be resumed!");
|
|
Console.WriteLine(e.Message);
|
|
}
|
|
}
|
|
|
|
// Check if the file exists and it is not a resume download
|
|
if (File.Exists(fn) && !_data.IsResume){
|
|
string rwts = _data.Override ?? "Y";
|
|
rwts = rwts.ToUpper(); // ?? "N"
|
|
|
|
if (rwts.StartsWith("Y")){
|
|
Console.WriteLine($"Deleting «{fn}»...");
|
|
File.Delete(fn);
|
|
} else if (rwts.StartsWith("C")){
|
|
return (Ok: true, _data.Parts);
|
|
} else{
|
|
return (Ok: false, _data.Parts);
|
|
}
|
|
}
|
|
|
|
// Show output filename based on whether it's a resume
|
|
if (File.Exists(fn) && _data.IsResume){
|
|
Console.WriteLine($"Adding content to «{fn}»...");
|
|
} else{
|
|
Console.WriteLine($"Saving stream to «{fn}»...");
|
|
}
|
|
|
|
|
|
// Start time
|
|
_data.DateStart = DateTimeOffset.Now.ToUnixTimeMilliseconds();
|
|
|
|
if (_data.M3U8Json != null){
|
|
List<dynamic> segments = _data.M3U8Json.Segments;
|
|
|
|
// map has init uri outside is none init uri
|
|
// Download init part
|
|
if (segments[0].map != null && _data.Offset == 0 && !_data.SkipInit){
|
|
Console.WriteLine("Download and save init part...");
|
|
Segment initSeg = new Segment();
|
|
initSeg.Uri = ObjectUtilities.GetMemberValue(segments[0].map, "uri");
|
|
initSeg.Key = ObjectUtilities.GetMemberValue(segments[0].map, "key");
|
|
initSeg.ByteRange = ObjectUtilities.GetMemberValue(segments[0].map, "byteRange");
|
|
|
|
if (ObjectUtilities.GetMemberValue(segments[0], "key") != null){
|
|
initSeg.Key = segments[0].Key;
|
|
}
|
|
|
|
try{
|
|
var initDl = await DownloadPart(initSeg, 0, 0);
|
|
await File.WriteAllBytesAsync(fn, initDl);
|
|
await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count }));
|
|
Console.WriteLine("Init part downloaded.");
|
|
} catch (Exception e){
|
|
Console.Error.WriteLine($"Part init download error:\n\t{e.Message}");
|
|
return (false, this._data.Parts);
|
|
}
|
|
} else if (segments[0].map != null && this._data.Offset == 0 && this._data.SkipInit){
|
|
Console.WriteLine("Skipping init part can lead to broken video!");
|
|
}
|
|
|
|
// Resuming ...
|
|
if (_data.Offset > 0){
|
|
segments = segments.GetRange(_data.Offset, segments.Count - _data.Offset);
|
|
Console.WriteLine($"Resuming download from part {_data.Offset + 1}...");
|
|
_data.Parts.Completed = _data.Offset;
|
|
}
|
|
|
|
for (int p = 0; p < Math.Ceiling((double)segments.Count / _data.Threads); p++){
|
|
int offset = p * _data.Threads;
|
|
int dlOffset = Math.Min(offset + _data.Threads, segments.Count);
|
|
|
|
int errorCount = 0;
|
|
Dictionary<string, Task> keyTasks = new Dictionary<string, Task>();
|
|
Dictionary<int, Task<byte[]>> partTasks = new Dictionary<int, Task<byte[]>>();
|
|
List<byte[]> results = new List<byte[]>(new byte[dlOffset - offset][]);
|
|
|
|
// Download keys
|
|
for (int px = offset; px < dlOffset; px++){
|
|
var curSegment = segments[px];
|
|
var key = ObjectUtilities.GetMemberValue(curSegment, "key");
|
|
if (key != null && !keyTasks.ContainsKey(key?.Uri) && !_data.Keys.ContainsKey(key?.Uri)){
|
|
keyTasks[curSegment.Key.Uri] = DownloadKey(curSegment.Key, px, _data.Offset);
|
|
}
|
|
}
|
|
|
|
try{
|
|
await Task.WhenAll(keyTasks.Values);
|
|
} catch (Exception ex){
|
|
Console.WriteLine($"Error downloading keys: {ex.Message}");
|
|
throw;
|
|
}
|
|
|
|
for (int px = offset; px < dlOffset && px < segments.Count; px++){
|
|
var segment = new Segment();
|
|
segment.Uri = ObjectUtilities.GetMemberValue(segments[px], "uri");
|
|
segment.Key = ObjectUtilities.GetMemberValue(segments[px], "key");
|
|
segment.ByteRange = ObjectUtilities.GetMemberValue(segments[px], "byteRange");
|
|
partTasks[px] = DownloadPart(segment, px, _data.Offset);
|
|
}
|
|
|
|
while (partTasks.Count > 0){
|
|
Task<byte[]> completedTask = await Task.WhenAny(partTasks.Values);
|
|
int completedIndex = -1;
|
|
foreach (var task in partTasks){
|
|
if (task.Value == completedTask){
|
|
completedIndex = task.Key;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (completedIndex != -1){
|
|
try{
|
|
byte[] result = await completedTask;
|
|
results[completedIndex - offset] = result;
|
|
partTasks.Remove(completedIndex);
|
|
} catch (Exception ex){
|
|
Console.Error.WriteLine($"Part {completedIndex + 1 + _data.Offset} download error:\n\t{ex.Message}");
|
|
partTasks.Remove(completedIndex);
|
|
errorCount++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (errorCount > 0){
|
|
Console.Error.WriteLine($"{errorCount} parts not downloaded");
|
|
return (false, _data.Parts);
|
|
}
|
|
|
|
foreach (var part in results){
|
|
int attempt = 0;
|
|
bool writeSuccess = false;
|
|
|
|
while (attempt < 3 && !writeSuccess){
|
|
try{
|
|
using (var stream = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){
|
|
await stream.WriteAsync(part, 0, part.Length);
|
|
}
|
|
|
|
writeSuccess = true;
|
|
} catch (Exception ex){
|
|
Console.Error.WriteLine(ex);
|
|
Console.Error.WriteLine($"Unable to write to file '{fn}' (Attempt {attempt + 1}/3)");
|
|
Console.WriteLine($"Waiting {Math.Round(_data.WaitTime / 1000.0)}s before retrying");
|
|
await Task.Delay(_data.WaitTime);
|
|
attempt++;
|
|
}
|
|
}
|
|
|
|
if (!writeSuccess){
|
|
Console.Error.WriteLine($"Unable to write content to '{fn}'.");
|
|
return (Ok: false, _data.Parts);
|
|
}
|
|
}
|
|
|
|
int totalSeg = _data.Parts.Total; // + _data.Offset
|
|
int downloadedSeg = Math.Min(dlOffset, totalSeg);
|
|
_data.Parts.Completed = downloadedSeg + _data.Offset; //
|
|
|
|
var dataLog = GetDownloadInfo(_data.DateStart, _data.Parts.Completed, totalSeg, _data.BytesDownloaded);
|
|
|
|
// Save resume data to file
|
|
string resumeDataJson = JsonConvert.SerializeObject(new{ _data.Parts.Completed, Total = totalSeg });
|
|
File.WriteAllText($"{fn}.resume", resumeDataJson);
|
|
|
|
// Log progress
|
|
Console.WriteLine($"{_data.Parts.Completed} of {totalSeg} parts downloaded [{dataLog.Percent}%] ({FormatTime(dataLog.Time / 1000)} | {dataLog.DownloadSpeed / 1000000.0:F2}Mb/s)");
|
|
|
|
_currentEpMeta.DownloadProgress = new DownloadProgress(){
|
|
IsDownloading = true,
|
|
Percent = dataLog.Percent,
|
|
Time = dataLog.Time,
|
|
DownloadSpeed = dataLog.DownloadSpeed,
|
|
Doing = _isAudio ? "Downloading Audio" : (_isVideo ? "Downloading Video" : "")
|
|
};
|
|
|
|
if (!Crunchyroll.Instance.Queue.Contains(_currentEpMeta)){
|
|
return (Ok: false, _data.Parts);
|
|
}
|
|
|
|
Crunchyroll.Instance.Queue.Refresh();
|
|
|
|
while (_currentEpMeta.Paused){
|
|
await Task.Delay(500);
|
|
if (!Crunchyroll.Instance.Queue.Contains(_currentEpMeta)){
|
|
return (Ok: false, _data.Parts);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return (Ok: true, _data.Parts);
|
|
}
|
|
|
|
public static Info GetDownloadInfo(long dateStartUnix, int partsDownloaded, int partsTotal, long downloadedBytes){
|
|
// Convert Unix timestamp to DateTime
|
|
DateTime dateStart = DateTimeOffset.FromUnixTimeMilliseconds(dateStartUnix).UtcDateTime;
|
|
double dateElapsed = (DateTime.UtcNow - dateStart).TotalMilliseconds;
|
|
|
|
// Calculate percentage
|
|
int percentFixed = (int)((double)partsDownloaded / partsTotal * 100);
|
|
int percent = percentFixed < 100 ? percentFixed : (partsTotal == partsDownloaded ? 100 : 99);
|
|
|
|
// Calculate remaining time estimate
|
|
double remainingTime = dateElapsed * (partsTotal / (double)partsDownloaded - 1);
|
|
|
|
// Calculate download speed (bytes per second)
|
|
double downloadSpeed = downloadedBytes / (dateElapsed / 1000);
|
|
|
|
return new Info{
|
|
Percent = percent,
|
|
Time = remainingTime,
|
|
DownloadSpeed = downloadSpeed
|
|
};
|
|
}
|
|
|
|
private string FormatTime(double seconds){
|
|
TimeSpan timeSpan = TimeSpan.FromSeconds(seconds);
|
|
return timeSpan.ToString(@"hh\:mm\:ss");
|
|
}
|
|
|
|
public async Task<byte[]> DownloadPart(Segment seg, int segIndex, int segOffset){
|
|
string sUri = GetUri(seg.Uri ?? "", _data.BaseUrl);
|
|
byte[]? dec = null;
|
|
int p = segIndex;
|
|
try{
|
|
byte[]? part;
|
|
if (seg.Key != null){
|
|
var decipher = await GetKey(seg.Key, p, segOffset);
|
|
part = await GetData(p, sUri, seg.ByteRange != null ? seg.ByteRange.ToDictionary() : new Dictionary<string, string>(), segOffset, false, _data.Timeout, _data.Retries);
|
|
var partContent = part;
|
|
using (decipher){
|
|
if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length);
|
|
}
|
|
|
|
if (dec != null) _data.BytesDownloaded += dec.Length;
|
|
} else{
|
|
part = await GetData(p, sUri, seg.ByteRange != null ? seg.ByteRange.ToDictionary() : new Dictionary<string, string>(), segOffset, false, _data.Timeout, _data.Retries);
|
|
dec = part;
|
|
if (dec != null) _data.BytesDownloaded += dec.Length;
|
|
}
|
|
} catch (Exception ex){
|
|
throw new Exception($"Error at segment {p}: {ex.Message}", ex);
|
|
}
|
|
|
|
return dec ?? Array.Empty<byte>();
|
|
}
|
|
|
|
private async Task<ICryptoTransform> GetKey(Key key, int segIndex, int segOffset){
|
|
string kUri = GetUri(key.Uri ?? "", _data.BaseUrl);
|
|
int p = segIndex;
|
|
if (!_data.Keys.ContainsKey(kUri)){
|
|
try{
|
|
var rkey = await DownloadKey(key, segIndex, segOffset);
|
|
if (rkey == null)
|
|
throw new Exception("Failed to download key");
|
|
_data.Keys[kUri] = rkey;
|
|
} catch (Exception ex){
|
|
throw new Exception($"Error at segment {p}: {ex.Message}", ex);
|
|
}
|
|
}
|
|
|
|
byte[] iv = new byte[16];
|
|
var ivs = key.Iv; //?? new List<int>{ 0, 0, 0, p + 1 }
|
|
for (int i = 0; i < ivs.Count; i++){
|
|
byte[] bytes = BitConverter.GetBytes(ivs[i]);
|
|
|
|
// Ensure the bytes are in big-endian order
|
|
if (BitConverter.IsLittleEndian){
|
|
Array.Reverse(bytes);
|
|
}
|
|
|
|
bytes.CopyTo(iv, i * 4);
|
|
}
|
|
|
|
ICryptoTransform decryptor;
|
|
using (Aes aes = Aes.Create()){
|
|
aes.Key = _data.Keys[kUri];
|
|
aes.IV = iv;
|
|
aes.Mode = CipherMode.CBC;
|
|
aes.Padding = PaddingMode.PKCS7;
|
|
decryptor = aes.CreateDecryptor();
|
|
}
|
|
|
|
// var decryptor = new AesCryptoServiceProvider{
|
|
// Key = _data.Keys[kUri],
|
|
// IV = iv,
|
|
// Mode = CipherMode.CBC,
|
|
// Padding = PaddingMode.PKCS7
|
|
// }.CreateDecryptor();
|
|
return decryptor;
|
|
}
|
|
|
|
public async Task<byte[]> DownloadKey(Key key, int segIndex, int segOffset){
|
|
string kUri = GetUri(key.Uri ?? "", _data.BaseUrl);
|
|
if (!_data.Keys.ContainsKey(kUri)){
|
|
try{
|
|
var rkey = await GetData(segIndex, kUri, new Dictionary<string, string>(), segOffset, true, _data.Timeout, _data.Retries);
|
|
if (rkey == null || rkey.Length != 16){
|
|
throw new Exception("Key not fully downloaded or is incorrect.");
|
|
}
|
|
|
|
_data.Keys[kUri] = rkey;
|
|
return rkey;
|
|
} catch (Exception ex){
|
|
ex.Data["SegmentIndex"] = segIndex; // Adding custom data to the exception
|
|
throw;
|
|
}
|
|
}
|
|
|
|
return _data.Keys[kUri];
|
|
}
|
|
|
|
public async Task<byte[]?> GetData(int partIndex, string uri, IDictionary<string, string> headers, int segOffset, bool isKey, int timeout, int retryCount){
|
|
// Handle local file URI
|
|
if (uri.StartsWith("file://")){
|
|
string path = new Uri(uri).LocalPath;
|
|
return File.ReadAllBytes(path);
|
|
}
|
|
|
|
// Setup request headers
|
|
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri);
|
|
foreach (var header in headers){
|
|
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
|
}
|
|
|
|
// Set default user-agent if not provided
|
|
if (!request.Headers.Contains("User-Agent")){
|
|
request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:70.0) Gecko/20100101 Firefox/70.0");
|
|
}
|
|
|
|
return await SendRequestWithRetry(request, partIndex, segOffset, isKey, retryCount);
|
|
}
|
|
|
|
private async Task<byte[]?> SendRequestWithRetry(HttpRequestMessage requestPara, int partIndex, int segOffset, bool isKey, int retryCount){
|
|
HttpResponseMessage response;
|
|
for (int attempt = 0; attempt < retryCount + 1; attempt++){
|
|
using (var request = CloneHttpRequestMessage(requestPara)){
|
|
try{
|
|
response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseContentRead);
|
|
response.EnsureSuccessStatusCode();
|
|
return await response.Content.ReadAsByteArrayAsync();
|
|
} catch (HttpRequestException ex){
|
|
// Log retry attempts
|
|
string partType = isKey ? "Key" : "Part";
|
|
int partIndx = partIndex + 1 + segOffset;
|
|
Console.WriteLine($"{partType} {partIndx}: Attempt {attempt + 1} to retrieve data failed.");
|
|
Console.WriteLine($"\tError: {ex.Message}");
|
|
if (attempt == retryCount)
|
|
throw; // rethrow after last retry
|
|
}
|
|
}
|
|
}
|
|
|
|
return null; // Should not reach here
|
|
}
|
|
|
|
private HttpRequestMessage CloneHttpRequestMessage(HttpRequestMessage originalRequest){
|
|
var clone = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri){
|
|
Content = originalRequest.Content?.Clone(),
|
|
Version = originalRequest.Version
|
|
};
|
|
foreach (var header in originalRequest.Headers){
|
|
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
|
}
|
|
|
|
foreach (var property in originalRequest.Properties){
|
|
clone.Properties.Add(property);
|
|
}
|
|
|
|
return clone;
|
|
}
|
|
|
|
|
|
private static string GetUri(string uri, string? baseUrl = null){
|
|
bool httpUri = Regex.IsMatch(uri, @"^https?:", RegexOptions.IgnoreCase);
|
|
if (string.IsNullOrEmpty(baseUrl) && !httpUri){
|
|
throw new ArgumentException("No base and not http(s) uri");
|
|
} else if (httpUri){
|
|
return uri;
|
|
}
|
|
|
|
return baseUrl + uri;
|
|
}
|
|
}
|
|
|
|
public static class HttpContentExtensions{
|
|
public static HttpContent Clone(this HttpContent content){
|
|
if (content == null) return null;
|
|
var memStream = new MemoryStream();
|
|
content.CopyToAsync(memStream).Wait();
|
|
memStream.Position = 0;
|
|
var newContent = new StreamContent(memStream);
|
|
foreach (var header in content.Headers){
|
|
newContent.Headers.Add(header.Key, header.Value);
|
|
}
|
|
|
|
return newContent;
|
|
}
|
|
}
|
|
|
|
public class Info{
|
|
public int Percent{ get; set; }
|
|
public double Time{ get; set; } // Remaining time estimate
|
|
public double DownloadSpeed{ get; set; } // Bytes per second
|
|
}
|
|
|
|
public class ResumeData{
|
|
public int Total{ get; set; }
|
|
public int Completed{ get; set; }
|
|
}
|
|
|
|
public class M3U8Json{
|
|
public dynamic Segments{ get; set; } = new List<dynamic>();
|
|
public int? MediaSequence{ get; set; }
|
|
}
|
|
|
|
public class Segment{
|
|
public string? Uri{ get; set; }
|
|
public Key? Key{ get; set; }
|
|
public ByteRange? ByteRange{ get; set; }
|
|
}
|
|
|
|
public class Key{
|
|
public string? Uri{ get; set; }
|
|
public List<int> Iv{ get; set; } = new List<int>();
|
|
}
|
|
|
|
public class ByteRange{
|
|
public long Offset{ get; set; }
|
|
public long Length{ get; set; }
|
|
|
|
public IDictionary<string, string> ToDictionary(){
|
|
return new Dictionary<string, string>{
|
|
{ "Offset", Offset.ToString() },
|
|
{ "Length", Length.ToString() }
|
|
};
|
|
}
|
|
}
|
|
|
|
public class HlsOptions{
|
|
public M3U8Json? M3U8Json{ get; set; }
|
|
public string? Output{ get; set; }
|
|
public int? Threads{ get; set; }
|
|
public int? Retries{ get; set; }
|
|
public int? Offset{ get; set; }
|
|
public string? BaseUrl{ get; set; }
|
|
public bool? SkipInit{ get; set; }
|
|
public int? Timeout{ get; set; }
|
|
public int? FsRetryTime{ get; set; }
|
|
public string? Override{ get; set; }
|
|
}
|
|
|
|
public class Data{
|
|
public PartsData Parts{ get; set; } = new PartsData();
|
|
public M3U8Json? M3U8Json{ get; set; }
|
|
public string? OutputFile{ get; set; }
|
|
public int Threads{ get; set; }
|
|
public int Retries{ get; set; }
|
|
public int Offset{ get; set; }
|
|
public string? BaseUrl{ get; set; }
|
|
public bool SkipInit{ get; set; }
|
|
public Dictionary<string, byte[]> Keys{ get; set; } = new Dictionary<string, byte[]>(); // Object can be Buffer or string
|
|
public int Timeout{ get; set; }
|
|
public bool CheckPartLength{ get; set; }
|
|
public bool IsResume{ get; set; }
|
|
public long BytesDownloaded{ get; set; }
|
|
public int WaitTime{ get; set; }
|
|
public string? Override{ get; set; }
|
|
public long DateStart{ get; set; }
|
|
}
|
|
|
|
public class ProgressData{
|
|
public int Total{ get; set; }
|
|
|
|
public int Cur{ get; set; }
|
|
|
|
// Considering the dual type in TypeScript (number|string), you might opt for string in C# to accommodate both numeric and text representations.
|
|
// Alternatively, you could use a custom setter to handle numeric inputs as strings, or define two separate properties if the usage context is clear.
|
|
public string? Percent{ get; set; }
|
|
public double Time{ get; set; } // Assuming this represents a duration or timestamp, you might consider TimeSpan or DateTime based on context.
|
|
public double DownloadSpeed{ get; set; }
|
|
public long Bytes{ get; set; }
|
|
}
|
|
|
|
public class DownloadInfo{
|
|
public string? Image{ get; set; }
|
|
|
|
public Parent? Parent{ get; set; }
|
|
public string? Title{ get; set; }
|
|
public LanguageItem? Language{ get; set; }
|
|
public string? FileName{ get; set; }
|
|
}
|
|
|
|
public class Parent{
|
|
public string? Title{ get; set; }
|
|
}
|
|
|
|
public class PartsData{
|
|
public int First{ get; set; }
|
|
public int Total{ get; set; }
|
|
public int Completed{ get; set; }
|
|
} |