v1.4.1
This commit is contained in:
parent
77cd38446d
commit
1cc14103df
|
@ -0,0 +1,20 @@
|
|||
<Application xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:crd="clr-namespace:CRD"
|
||||
xmlns:sty="clr-namespace:FluentAvalonia.Styling;assembly=FluentAvalonia"
|
||||
x:Class="CRD.App"
|
||||
RequestedThemeVariant="Dark">
|
||||
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
|
||||
|
||||
<Application.DataTemplates>
|
||||
<crd:ViewLocator/>
|
||||
</Application.DataTemplates>
|
||||
|
||||
|
||||
|
||||
<Application.Styles>
|
||||
<sty:FluentAvaloniaTheme/>
|
||||
<StyleInclude Source="avares://CRD/Styling/ControlsGalleryStyles.axaml" />
|
||||
<StyleInclude Source="avares://CRD/Assets/Icons.axaml"></StyleInclude>
|
||||
</Application.Styles>
|
||||
</Application>
|
|
@ -0,0 +1,23 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using CRD.ViewModels;
|
||||
using MainWindow = CRD.Views.MainWindow;
|
||||
|
||||
namespace CRD;
|
||||
|
||||
public partial class App : Application{
|
||||
public override void Initialize(){
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted(){
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop){
|
||||
desktop.MainWindow = new MainWindow{
|
||||
DataContext = new MainWindowViewModel(),
|
||||
};
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Style>
|
||||
<Style.Resources>
|
||||
<StreamGeometry x:Key="LineHorizontal3Regular">M2 4.5C2 4.22386 2.22386 4 2.5 4H17.5C17.7761 4 18 4.22386 18 4.5C18 4.77614 17.7761 5 17.5 5H2.5C2.22386 5 2 4.77614 2 4.5Z M2 9.5C2 9.22386 2.22386 9 2.5 9H17.5C17.7761 9 18 9.22386 18 9.5C18 9.77614 17.7761 10 17.5 10H2.5C2.22386 10 2 9.77614 2 9.5Z M2.5 14C2.22386 14 2 14.2239 2 14.5C2 14.7761 2.22386 15 2.5 15H17.5C17.7761 15 18 14.7761 18 14.5C18 14.2239 17.7761 14 17.5 14H2.5Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="ArrowDownloadRegular">M12.25,39.5 L35.75,39.5 C36.4403559,39.5 37,40.0596441 37,40.75 C37,41.3972087 36.5081253,41.9295339 35.8778052,41.9935464 L35.75,42 L12.25,42 C11.5596441,42 11,41.4403559 11,40.75 C11,40.1027913 11.4918747,39.5704661 12.1221948,39.5064536 L12.25,39.5 L35.75,39.5 L12.25,39.5 Z M23.6221948,6.00645361 L23.75,6 C24.3972087,6 24.9295339,6.49187466 24.9935464,7.12219476 L25,7.25 L25,31.54 L30.6466793,25.8942911 C31.1348346,25.4061358 31.9262909,25.4061358 32.4144462,25.8942911 C32.9026016,26.3824465 32.9026016,27.1739027 32.4144462,27.6620581 L24.6362716,35.4402327 C24.1481163,35.928388 23.35666,35.928388 22.8685047,35.4402327 L15.0903301,27.6620581 C14.6021747,27.1739027 14.6021747,26.3824465 15.0903301,25.8942911 C15.5784855,25.4061358 16.3699417,25.4061358 16.858097,25.8942911 L22.5,31.536 L22.5,7.25 C22.5,6.60279131 22.9918747,6.0704661 23.6221948,6.00645361 L23.75,6 L23.6221948,6.00645361 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="SettingsRegular">M14 9.50006C11.5147 9.50006 9.5 11.5148 9.5 14.0001C9.5 16.4853 11.5147 18.5001 14 18.5001C15.3488 18.5001 16.559 17.9066 17.3838 16.9666C18.0787 16.1746 18.5 15.1365 18.5 14.0001C18.5 13.5401 18.431 13.0963 18.3028 12.6784C17.7382 10.8381 16.0253 9.50006 14 9.50006ZM11 14.0001C11 12.3432 12.3431 11.0001 14 11.0001C15.6569 11.0001 17 12.3432 17 14.0001C17 15.6569 15.6569 17.0001 14 17.0001C12.3431 17.0001 11 15.6569 11 14.0001Z M21.7093 22.3948L19.9818 21.6364C19.4876 21.4197 18.9071 21.4515 18.44 21.7219C17.9729 21.9924 17.675 22.4693 17.6157 23.0066L17.408 24.8855C17.3651 25.273 17.084 25.5917 16.7055 25.682C14.9263 26.1061 13.0725 26.1061 11.2933 25.682C10.9148 25.5917 10.6336 25.273 10.5908 24.8855L10.3834 23.0093C10.3225 22.4731 10.0112 21.9976 9.54452 21.7281C9.07783 21.4586 8.51117 21.4269 8.01859 21.6424L6.29071 22.4009C5.93281 22.558 5.51493 22.4718 5.24806 22.1859C4.00474 20.8536 3.07924 19.2561 2.54122 17.5137C2.42533 17.1384 2.55922 16.7307 2.8749 16.4977L4.40219 15.3703C4.83721 15.0501 5.09414 14.5415 5.09414 14.0007C5.09414 13.4598 4.83721 12.9512 4.40162 12.6306L2.87529 11.5051C2.55914 11.272 2.42513 10.8638 2.54142 10.4882C3.08038 8.74734 4.00637 7.15163 5.24971 5.82114C5.51684 5.53528 5.93492 5.44941 6.29276 5.60691L8.01296 6.36404C8.50793 6.58168 9.07696 6.54881 9.54617 6.27415C10.0133 6.00264 10.3244 5.52527 10.3844 4.98794L10.5933 3.11017C10.637 2.71803 10.9245 2.39704 11.3089 2.31138C12.19 2.11504 13.0891 2.01071 14.0131 2.00006C14.9147 2.01047 15.8128 2.11485 16.6928 2.31149C17.077 2.39734 17.3643 2.71823 17.4079 3.11017L17.617 4.98937C17.7116 5.85221 18.4387 6.50572 19.3055 6.50663C19.5385 6.507 19.769 6.45838 19.9843 6.36294L21.7048 5.60568C22.0626 5.44818 22.4807 5.53405 22.7478 5.81991C23.9912 7.1504 24.9172 8.74611 25.4561 10.487C25.5723 10.8623 25.4386 11.2703 25.1228 11.5035L23.5978 12.6297C23.1628 12.95 22.9 13.4586 22.9 13.9994C22.9 14.5403 23.1628 15.0489 23.5988 15.3698L25.1251 16.4965C25.441 16.7296 25.5748 17.1376 25.4586 17.5131C24.9198 19.2536 23.9944 20.8492 22.7517 22.1799C22.4849 22.4657 22.0671 22.5518 21.7093 22.3948ZM16.263 22.1966C16.4982 21.4685 16.9889 20.8288 17.6884 20.4238C18.5702 19.9132 19.6536 19.8547 20.5841 20.2627L21.9281 20.8526C22.791 19.8538 23.4593 18.7013 23.8981 17.4552L22.7095 16.5778L22.7086 16.5771C21.898 15.98 21.4 15.0277 21.4 13.9994C21.4 12.9719 21.8974 12.0195 22.7073 11.4227L22.7085 11.4218L23.8957 10.545C23.4567 9.2988 22.7881 8.14636 21.9248 7.1477L20.5922 7.73425L20.5899 7.73527C20.1844 7.91463 19.7472 8.00722 19.3039 8.00663C17.6715 8.00453 16.3046 6.77431 16.1261 5.15465L16.1259 5.15291L15.9635 3.69304C15.3202 3.57328 14.6677 3.50872 14.013 3.50017C13.3389 3.50891 12.6821 3.57367 12.0377 3.69328L11.8751 5.15452C11.7625 6.16272 11.1793 7.05909 10.3019 7.56986C9.41937 8.0856 8.34453 8.14844 7.40869 7.73694L6.07273 7.14893C5.20949 8.14751 4.54092 9.29983 4.10196 10.5459L5.29181 11.4233C6.11115 12.0269 6.59414 12.9837 6.59414 14.0007C6.59414 15.0173 6.11142 15.9742 5.29237 16.5776L4.10161 17.4566C4.54002 18.7044 5.2085 19.8585 6.07205 20.8587L7.41742 20.2682C8.34745 19.8613 9.41573 19.9215 10.2947 20.4292C11.174 20.937 11.7593 21.832 11.8738 22.84L11.8744 22.8445L12.0362 24.3088C13.3326 24.5638 14.6662 24.5638 15.9626 24.3088L16.1247 22.8418C16.1491 22.6217 16.1955 22.4055 16.263 22.1966Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="AddRegular">M14.5,13 L14.5,3.75378577 C14.5,3.33978577 14.164,3.00378577 13.75,3.00378577 C13.336,3.00378577 13,3.33978577 13,3.75378577 L13,13 L3.75387573,13 C3.33987573,13 3.00387573,13.336 3.00387573,13.75 C3.00387573,14.164 3.33987573,14.5 3.75387573,14.5 L13,14.5 L13,23.7523651 C13,24.1663651 13.336,24.5023651 13.75,24.5023651 C14.164,24.5023651 14.5,24.1663651 14.5,23.7523651 L14.5,14.5 L23.7498262,14.5030754 C24.1638262,14.5030754 24.4998262,14.1670754 24.4998262,13.7530754 C24.4998262,13.3390754 24.1638262,13.0030754 23.7498262,13.0030754 L14.5,13 Z</StreamGeometry>
|
||||
</Style.Resources>
|
||||
</Style>
|
||||
</Styles>
|
Binary file not shown.
After Width: | Height: | Size: 157 KiB |
|
@ -0,0 +1,223 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using Newtonsoft.Json;
|
||||
using YamlDotNet.Core.Tokens;
|
||||
|
||||
namespace CRD.Downloader;
|
||||
|
||||
public class CrAuth(Crunchyroll crunInstance){
|
||||
public async Task AuthAnonymous(){
|
||||
var formData = new Dictionary<string, string>{
|
||||
{ "grant_type", "client_id" },
|
||||
{ "scope", "offline_access" }
|
||||
};
|
||||
var requestContent = new FormUrlEncodedContent(formData);
|
||||
requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, Api.BetaAuth){
|
||||
Content = requestContent
|
||||
};
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Api.authBasicSwitch);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
||||
if (response.IsOk){
|
||||
JsonTokenToFileAndVariable(response.ResponseContent);
|
||||
} else{
|
||||
Console.WriteLine("Anonymous login failed");
|
||||
}
|
||||
|
||||
crunInstance.Profile = new CrProfile{
|
||||
Username = "???",
|
||||
Avatar = "003-cr-hime-excited.png",
|
||||
PreferredContentAudioLanguage = "ja-JP",
|
||||
PreferredContentSubtitleLanguage = "de-DE"
|
||||
};
|
||||
|
||||
Crunchyroll.Instance.CmsToken = new CrCmsToken();
|
||||
|
||||
}
|
||||
|
||||
private void JsonTokenToFileAndVariable(string content){
|
||||
crunInstance.Token = JsonConvert.DeserializeObject<CrToken>(content, crunInstance.SettingsJsonSerializerSettings);
|
||||
|
||||
|
||||
if (crunInstance.Token != null && crunInstance.Token.expires_in != null){
|
||||
crunInstance.Token.expires = DateTime.Now.AddMilliseconds((double)crunInstance.Token.expires_in);
|
||||
|
||||
CfgManager.WriteTokenToYamlFile(crunInstance.Token, CfgManager.PathCrToken);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task Auth(AuthData data){
|
||||
var formData = new Dictionary<string, string>{
|
||||
{ "username", data.Username },
|
||||
{ "password", data.Password },
|
||||
{ "grant_type", "password" },
|
||||
{ "scope", "offline_access" }
|
||||
};
|
||||
var requestContent = new FormUrlEncodedContent(formData);
|
||||
requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, Api.BetaAuth){
|
||||
Content = requestContent
|
||||
};
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Api.authBasicSwitch);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
||||
if (response.IsOk){
|
||||
JsonTokenToFileAndVariable(response.ResponseContent);
|
||||
}
|
||||
|
||||
if (crunInstance.Token?.refresh_token != null){
|
||||
HttpClientReq.Instance.SetETPCookie(crunInstance.Token.refresh_token);
|
||||
|
||||
await GetProfile();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task GetProfile(){
|
||||
if (crunInstance.Token?.access_token == null){
|
||||
Console.Error.WriteLine("Missing Access Token");
|
||||
return;
|
||||
}
|
||||
|
||||
var request = HttpClientReq.CreateRequestMessage(Api.BetaProfile, HttpMethod.Get, true, true, null);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
||||
if (response.IsOk){
|
||||
var profileTemp = Helpers.Deserialize<CrProfile>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
|
||||
|
||||
if (profileTemp != null){
|
||||
crunInstance.Profile = profileTemp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async void LoginWithToken(){
|
||||
if (crunInstance.Token?.refresh_token == null){
|
||||
Console.WriteLine("Missing Refresh Token");
|
||||
return;
|
||||
}
|
||||
|
||||
var formData = new Dictionary<string, string>{
|
||||
{ "refresh_token", crunInstance.Token.refresh_token },
|
||||
{ "grant_type", "refresh_token" },
|
||||
{ "scope", "offline_access" }
|
||||
};
|
||||
var requestContent = new FormUrlEncodedContent(formData);
|
||||
requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, Api.BetaAuth){
|
||||
Content = requestContent
|
||||
};
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Api.authBasicSwitch);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
||||
if (response.IsOk){
|
||||
JsonTokenToFileAndVariable(response.ResponseContent);
|
||||
} else{
|
||||
Console.WriteLine("Token Auth Failed");
|
||||
}
|
||||
|
||||
if (crunInstance.Token?.refresh_token != null){
|
||||
await GetProfile();
|
||||
}
|
||||
|
||||
await GetCmsToken();
|
||||
}
|
||||
|
||||
public async Task RefreshToken(bool needsToken){
|
||||
if (crunInstance.Token?.access_token == null && crunInstance.Token?.refresh_token == null ||
|
||||
crunInstance.Token.access_token != null && crunInstance.Token.refresh_token == null){
|
||||
await AuthAnonymous();
|
||||
} else{
|
||||
if (!(DateTime.Now > crunInstance.Token.expires) && needsToken){
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var formData = new Dictionary<string, string>{
|
||||
{ "refresh_token", crunInstance.Token?.refresh_token ?? string.Empty },
|
||||
{ "grant_type", "refresh_token" },
|
||||
{ "scope", "offline_access" }
|
||||
};
|
||||
var requestContent = new FormUrlEncodedContent(formData);
|
||||
requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, Api.BetaAuth){
|
||||
Content = requestContent
|
||||
};
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Api.authBasicSwitch);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
||||
if (response.IsOk){
|
||||
JsonTokenToFileAndVariable(response.ResponseContent);
|
||||
} else{
|
||||
Console.WriteLine("Refresh Token Auth Failed");
|
||||
}
|
||||
|
||||
await GetCmsToken();
|
||||
}
|
||||
|
||||
|
||||
public async Task GetCmsToken(){
|
||||
if (crunInstance.Token?.access_token == null){
|
||||
Console.WriteLine($"Missing Access Token: {crunInstance.Token?.access_token}");
|
||||
return;
|
||||
}
|
||||
|
||||
var request = HttpClientReq.CreateRequestMessage(Api.BetaCmsToken, HttpMethod.Get, true, true, null);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
||||
if (response.IsOk){
|
||||
crunInstance.CmsToken = JsonConvert.DeserializeObject<CrCmsToken>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
|
||||
} else{
|
||||
Console.WriteLine("CMS Token Auth Failed");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task GetCmsData(){
|
||||
if (crunInstance.CmsToken?.Cms == null){
|
||||
Console.WriteLine("Missing CMS Token");
|
||||
return;
|
||||
}
|
||||
|
||||
UriBuilder uriBuilder = new UriBuilder(Api.BetaCms + crunInstance.CmsToken.Cms.Bucket + "/index?");
|
||||
|
||||
NameValueCollection query = HttpUtility.ParseQueryString(uriBuilder.Query);
|
||||
|
||||
query["preferred_audio_language"] = "ja-JP";
|
||||
query["Policy"] = crunInstance.CmsToken.Cms.Policy;
|
||||
query["Signature"] = crunInstance.CmsToken.Cms.Signature;
|
||||
query["Key-Pair-Id"] = crunInstance.CmsToken.Cms.KeyPairId;
|
||||
|
||||
uriBuilder.Query = query.ToString();
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.ToString());
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
||||
if (response.IsOk){
|
||||
Console.WriteLine(response.ResponseContent);
|
||||
} else{
|
||||
Console.WriteLine("Failed to Get CMS Index");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,249 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Downloader;
|
||||
|
||||
public class CrEpisode(Crunchyroll crunInstance){
|
||||
public async Task<CrunchyEpisodeList?> ParseEpisodeById(string id,string locale){
|
||||
if (crunInstance.CmsToken?.Cms == null){
|
||||
Console.WriteLine("Missing CMS Access Token");
|
||||
return null;
|
||||
}
|
||||
|
||||
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
|
||||
|
||||
query["preferred_audio_language"] = "ja-JP";
|
||||
query["locale"] = Languages.Locale2language(locale).CrLocale;
|
||||
|
||||
var request = HttpClientReq.CreateRequestMessage($"{Api.Cms}/episodes/{id}", HttpMethod.Get, true, true, query);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
||||
if (!response.IsOk){
|
||||
Console.WriteLine("Series Request Failed");
|
||||
return null;
|
||||
}
|
||||
|
||||
CrunchyEpisodeList epsidoe = Helpers.Deserialize<CrunchyEpisodeList>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
|
||||
|
||||
if (epsidoe.Total < 1){
|
||||
return null;
|
||||
}
|
||||
|
||||
return epsidoe;
|
||||
}
|
||||
|
||||
|
||||
public CrunchySeriesList EpisodeData(CrunchyEpisodeList dlEpisodes){
|
||||
bool serieshasversions = true;
|
||||
|
||||
Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
|
||||
|
||||
if (dlEpisodes.Data != null){
|
||||
foreach (var episode in dlEpisodes.Data){
|
||||
|
||||
if (crunInstance.CrunOptions.History){
|
||||
crunInstance.CrHistory.UpdateWithEpisode(episode);
|
||||
}
|
||||
|
||||
// Prepare the episode array
|
||||
EpisodeAndLanguage item;
|
||||
var seasonIdentifier = !string.IsNullOrEmpty(episode.Identifier) ? episode.Identifier.Split('|')[1] : $"S{episode.SeasonNumber}";
|
||||
var episodeKey = $"{seasonIdentifier}E{episode.Episode ?? (episode.EpisodeNumber + "")}";
|
||||
|
||||
if (!episodes.ContainsKey(episodeKey)){
|
||||
item = new EpisodeAndLanguage{
|
||||
Items = new List<CrunchyEpisode>(),
|
||||
Langs = new List<LanguageItem>()
|
||||
};
|
||||
episodes[episodeKey] = item;
|
||||
} else{
|
||||
item = episodes[episodeKey];
|
||||
}
|
||||
|
||||
if (episode.Versions != null){
|
||||
foreach (var version in episode.Versions){
|
||||
// Ensure there is only one of the same language
|
||||
if (item.Langs.All(a => a.CrLocale != version.AudioLocale)){
|
||||
// Push to arrays if there are no duplicates of the same language
|
||||
item.Items.Add(episode);
|
||||
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale));
|
||||
}
|
||||
}
|
||||
} else{
|
||||
// Episode didn't have versions, mark it as such to be logged.
|
||||
serieshasversions = false;
|
||||
// Ensure there is only one of the same language
|
||||
if (item.Langs.All(a => a.CrLocale != episode.AudioLocale)){
|
||||
// Push to arrays if there are no duplicates of the same language
|
||||
item.Items.Add(episode);
|
||||
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int specialIndex = 1;
|
||||
int epIndex = 1;
|
||||
|
||||
var keys = new List<string>(episodes.Keys); // Copying the keys to a new list to avoid modifying the collection while iterating.
|
||||
|
||||
foreach (var key in keys){
|
||||
EpisodeAndLanguage item = episodes[key];
|
||||
var isSpecial = !Regex.IsMatch(item.Items[0].Episode ?? string.Empty, @"^\d+$"); // Checking if the episode is not a number (i.e., special).
|
||||
string newKey;
|
||||
if (isSpecial && !string.IsNullOrEmpty(item.Items[0].Episode)){
|
||||
newKey = item.Items[0].Episode ?? "SP" + (specialIndex + " " + item.Items[0].Id);
|
||||
} else{
|
||||
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + item.Items[0].Id) : epIndex + "")}";
|
||||
}
|
||||
|
||||
episodes.Remove(key);
|
||||
episodes.Add(newKey, item);
|
||||
|
||||
if (isSpecial){
|
||||
specialIndex++;
|
||||
} else{
|
||||
epIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
var specials = episodes.Where(e => e.Key.StartsWith("SP")).ToList();
|
||||
var normal = episodes.Where(e => e.Key.StartsWith("E")).ToList();
|
||||
|
||||
// Combining and sorting episodes with normal first, then specials.
|
||||
var sortedEpisodes = new Dictionary<string, EpisodeAndLanguage>(normal.Concat(specials));
|
||||
|
||||
foreach (var kvp in sortedEpisodes){
|
||||
var key = kvp.Key;
|
||||
var item = kvp.Value;
|
||||
|
||||
var seasonTitle = item.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)")).SeasonTitle
|
||||
?? Regex.Replace(item.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
|
||||
var title = item.Items[0].Title;
|
||||
var seasonNumber = item.Items[0].SeasonNumber;
|
||||
|
||||
var languages = item.Items.Select((a, index) =>
|
||||
$"{(a.IsPremiumOnly ? "+ " : "")}{item.Langs.ElementAtOrDefault(index).Name ?? "Unknown"}").ToArray(); //☆
|
||||
|
||||
Console.WriteLine($"[{key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]");
|
||||
}
|
||||
|
||||
if (!serieshasversions){
|
||||
Console.WriteLine("Couldn\'t find versions on some episodes, fell back to old method.");
|
||||
}
|
||||
|
||||
CrunchySeriesList crunchySeriesList = new CrunchySeriesList();
|
||||
crunchySeriesList.Data = sortedEpisodes;
|
||||
|
||||
crunchySeriesList.List = sortedEpisodes.Select(kvp => {
|
||||
var key = kvp.Key;
|
||||
var value = kvp.Value;
|
||||
var images = (value.Items[0].Images?.Thumbnail ?? new List<List<Image>>{ new List<Image>{ new Image{ Source = "/notFound.png" } } });
|
||||
var seconds = (int)Math.Floor(value.Items[0].DurationMs / 1000.0);
|
||||
return new Episode{
|
||||
E = key.StartsWith("E") ? key.Substring(1) : key,
|
||||
Lang = value.Langs.Select(a => a.Code).ToList(),
|
||||
Name = value.Items[0].Title,
|
||||
Season = value.Items[0].SeasonNumber.ToString(),
|
||||
SeriesTitle = Regex.Replace(value.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd(),
|
||||
SeasonTitle = Regex.Replace(value.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(),
|
||||
EpisodeNum = value.Items[0].EpisodeNumber?.ToString() ?? value.Items[0].Episode ?? "?",
|
||||
Id = value.Items[0].SeasonId,
|
||||
Img = images[images.Count / 2].FirstOrDefault().Source,
|
||||
Description = value.Items[0].Description,
|
||||
Time = $"{seconds / 60}:{seconds % 60:D2}" // Ensures two digits for seconds.
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return crunchySeriesList;
|
||||
}
|
||||
|
||||
public Dictionary<string, CrunchyEpMeta> EpisodeMeta(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang){
|
||||
var ret = new Dictionary<string, CrunchyEpMeta>();
|
||||
|
||||
|
||||
foreach (var kvp in eps){
|
||||
var key = kvp.Key;
|
||||
var episode = kvp.Value;
|
||||
|
||||
for (int index = 0; index < episode.Items.Count; index++){
|
||||
var item = episode.Items[index];
|
||||
|
||||
if (!dubLang.Contains(episode.Langs[index].CrLocale))
|
||||
continue;
|
||||
|
||||
item.HideSeasonTitle = true;
|
||||
if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){
|
||||
item.SeasonTitle = item.SeriesTitle;
|
||||
item.HideSeasonTitle = false;
|
||||
item.HideSeasonNumber = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(item.SeasonTitle) && string.IsNullOrEmpty(item.SeriesTitle)){
|
||||
item.SeasonTitle = "NO_TITLE";
|
||||
item.SeriesTitle = "NO_TITLE";
|
||||
}
|
||||
|
||||
var epNum = key.StartsWith('E') ? key[1..] : key;
|
||||
var images = (item.Images?.Thumbnail ?? new List<List<Image>>{ new List<Image>{ new Image{ Source = "/notFound.png" } } });
|
||||
|
||||
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
|
||||
|
||||
var epMeta = new CrunchyEpMeta();
|
||||
epMeta.Data = new List<CrunchyEpMetaData>{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } };
|
||||
epMeta.SeriesTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeriesTitle)).SeriesTitle ?? Regex.Replace(episode.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
epMeta.SeasonTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeasonTitle)).SeasonTitle ?? Regex.Replace(episode.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
epMeta.EpisodeNumber = item.Episode;
|
||||
epMeta.EpisodeTitle = item.Title;
|
||||
epMeta.SeasonId = item.SeasonId;
|
||||
epMeta.Season = item.SeasonNumber;
|
||||
epMeta.ShowId = item.SeriesId;
|
||||
epMeta.AbsolutEpisodeNumberE = epNum;
|
||||
epMeta.Image = images[images.Count / 2].FirstOrDefault().Source;
|
||||
epMeta.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = false,
|
||||
Done = false,
|
||||
Error = false,
|
||||
Percent = 0,
|
||||
Time = 0,
|
||||
DownloadSpeed = 0
|
||||
};
|
||||
|
||||
var epMetaData = epMeta.Data[0];
|
||||
if (!string.IsNullOrEmpty(item.StreamsLink)){
|
||||
epMetaData.Playback = item.StreamsLink;
|
||||
if (string.IsNullOrEmpty(item.Playback)){
|
||||
item.Playback = item.StreamsLink;
|
||||
}
|
||||
}
|
||||
|
||||
if (ret.TryGetValue(key, out var epMe)){
|
||||
epMetaData.Lang = episode.Langs[index];
|
||||
epMe.Data?.Add(epMetaData);
|
||||
} else{
|
||||
epMetaData.Lang = episode.Langs[index];
|
||||
epMeta.Data[0] = epMetaData;
|
||||
ret.Add(key, epMeta);
|
||||
}
|
||||
|
||||
|
||||
// show ep
|
||||
item.SeqId = epNum;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return ret;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,406 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Downloader;
|
||||
|
||||
public class CrSeries(Crunchyroll crunInstance){
|
||||
public async Task<List<CrunchyEpMeta>> DownloadFromSeriesId(string id, CrunchyMultiDownload data){
|
||||
var series = await ListSeriesId(id, "" ,data);
|
||||
|
||||
if (series != null){
|
||||
var selected = ItemSelectMultiDub(series.Value.Data, data.DubLang, data.But, data.AllEpisodes, data.E);
|
||||
|
||||
foreach (var crunchyEpMeta in selected.Values){
|
||||
if (crunchyEpMeta.Data == null) continue;
|
||||
var languages = crunchyEpMeta.Data.Select((a) =>
|
||||
$" {a.Lang?.Name ?? "Unknown Language"}");
|
||||
|
||||
Console.WriteLine($"[S{crunchyEpMeta.Season}E{crunchyEpMeta.EpisodeNumber} - {crunchyEpMeta.EpisodeTitle} [{string.Join(", ", languages)}]");
|
||||
}
|
||||
|
||||
return selected.Values.ToList();
|
||||
}
|
||||
|
||||
return new List<CrunchyEpMeta>();
|
||||
}
|
||||
|
||||
public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? but, bool? all, List<string>? e){
|
||||
var ret = new Dictionary<string, CrunchyEpMeta>();
|
||||
|
||||
|
||||
foreach (var kvp in eps){
|
||||
var key = kvp.Key;
|
||||
var episode = kvp.Value;
|
||||
|
||||
for (int index = 0; index < episode.Items.Count; index++){
|
||||
var item = episode.Items[index];
|
||||
|
||||
if (!dubLang.Contains(episode.Langs[index].CrLocale))
|
||||
continue;
|
||||
|
||||
item.HideSeasonTitle = true;
|
||||
if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){
|
||||
item.SeasonTitle = item.SeriesTitle;
|
||||
item.HideSeasonTitle = false;
|
||||
item.HideSeasonNumber = true;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(item.SeasonTitle) && string.IsNullOrEmpty(item.SeriesTitle)){
|
||||
item.SeasonTitle = "NO_TITLE";
|
||||
item.SeriesTitle = "NO_TITLE";
|
||||
}
|
||||
|
||||
var epNum = key.StartsWith('E') ? key[1..] : key;
|
||||
var images = (item.Images?.Thumbnail ?? new List<List<Image>>{ new List<Image>{ new Image{ Source = "/notFound.png" } } });
|
||||
|
||||
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
|
||||
|
||||
var epMeta = new CrunchyEpMeta();
|
||||
epMeta.Data = new List<CrunchyEpMetaData>{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } };
|
||||
epMeta.SeriesTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeriesTitle)).SeriesTitle ?? Regex.Replace(episode.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
epMeta.SeasonTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeasonTitle)).SeasonTitle ?? Regex.Replace(episode.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
epMeta.EpisodeNumber = item.Episode;
|
||||
epMeta.EpisodeTitle = item.Title;
|
||||
epMeta.SeasonId = item.SeasonId;
|
||||
epMeta.Season = item.SeasonNumber;
|
||||
epMeta.ShowId = item.SeriesId;
|
||||
epMeta.AbsolutEpisodeNumberE = epNum;
|
||||
epMeta.Image = images[images.Count / 2].FirstOrDefault().Source;
|
||||
epMeta.DownloadProgress = new DownloadProgress(){
|
||||
IsDownloading = false,
|
||||
Done = false,
|
||||
Percent = 0,
|
||||
Time = 0,
|
||||
DownloadSpeed = 0
|
||||
};
|
||||
|
||||
|
||||
|
||||
var epMetaData = epMeta.Data[0];
|
||||
if (!string.IsNullOrEmpty(item.StreamsLink)){
|
||||
epMetaData.Playback = item.StreamsLink;
|
||||
if (string.IsNullOrEmpty(item.Playback)){
|
||||
item.Playback = item.StreamsLink;
|
||||
}
|
||||
}
|
||||
|
||||
if (all is true || e != null && e.Contains(epNum)){
|
||||
if (ret.TryGetValue(key, out var epMe)){
|
||||
epMetaData.Lang = episode.Langs[index];
|
||||
epMe.Data?.Add(epMetaData);
|
||||
} else{
|
||||
epMetaData.Lang = episode.Langs[index];
|
||||
epMeta.Data[0] = epMetaData;
|
||||
ret.Add(key, epMeta);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// show ep
|
||||
item.SeqId = epNum;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
public async Task<CrunchySeriesList?> ListSeriesId(string id,string Locale, CrunchyMultiDownload? data){
|
||||
await crunInstance.CrAuth.RefreshToken(true);
|
||||
|
||||
bool serieshasversions = true;
|
||||
|
||||
CrSeriesSearch? parsedSeries = await ParseSeriesById(id,Locale); // one piece - GRMG8ZQZR
|
||||
|
||||
if (parsedSeries == null){
|
||||
Console.WriteLine("Parse Data Invalid");
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = ParseSeriesResult(parsedSeries);
|
||||
Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
|
||||
|
||||
|
||||
foreach (int season in result.Keys){
|
||||
foreach (var key in result[season].Keys){
|
||||
var s = result[season][key];
|
||||
if (data?.S != null && s.Id != data.Value.S) continue;
|
||||
int fallbackIndex = 0;
|
||||
var seasonData = await GetSeasonDataById(s);
|
||||
if (seasonData.Data != null){
|
||||
|
||||
if (crunInstance.CrunOptions.History){
|
||||
crunInstance.CrHistory.UpdateWithSeasonData(seasonData);
|
||||
}
|
||||
|
||||
foreach (var episode in seasonData.Data){
|
||||
// Prepare the episode array
|
||||
EpisodeAndLanguage item;
|
||||
|
||||
|
||||
string episodeNum = (episode.Episode != String.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}")) ?? string.Empty;
|
||||
|
||||
var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier) ? s.Identifier.Split('|')[1] : $"S{episode.SeasonNumber}";
|
||||
var episodeKey = $"{seasonIdentifier}E{episodeNum}";
|
||||
|
||||
if (!episodes.ContainsKey(episodeKey)){
|
||||
item = new EpisodeAndLanguage{
|
||||
Items = new List<CrunchyEpisode>(),
|
||||
Langs = new List<LanguageItem>()
|
||||
};
|
||||
episodes[episodeKey] = item;
|
||||
} else{
|
||||
item = episodes[episodeKey];
|
||||
}
|
||||
|
||||
if (episode.Versions != null){
|
||||
foreach (var version in episode.Versions){
|
||||
// Ensure there is only one of the same language
|
||||
if (item.Langs.All(a => a.CrLocale != version.AudioLocale)){
|
||||
// Push to arrays if there are no duplicates of the same language
|
||||
item.Items.Add(episode);
|
||||
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale));
|
||||
}
|
||||
}
|
||||
} else{
|
||||
// Episode didn't have versions, mark it as such to be logged.
|
||||
serieshasversions = false;
|
||||
// Ensure there is only one of the same language
|
||||
if (item.Langs.All(a => a.CrLocale != episode.AudioLocale)){
|
||||
// Push to arrays if there are no duplicates of the same language
|
||||
item.Items.Add(episode);
|
||||
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int specialIndex = 1;
|
||||
int epIndex = 1;
|
||||
|
||||
var keys = new List<string>(episodes.Keys); // Copying the keys to a new list to avoid modifying the collection while iterating.
|
||||
|
||||
foreach (var key in keys){
|
||||
EpisodeAndLanguage item = episodes[key];
|
||||
var episode = item.Items[0].Episode;
|
||||
var isSpecial = episode != null && !Regex.IsMatch(episode, @"^\d+$"); // Checking if the episode is not a number (i.e., special).
|
||||
// var newKey = $"{(isSpecial ? 'S' : 'E')}{(isSpecial ? specialIndex : epIndex).ToString()}";
|
||||
|
||||
string newKey;
|
||||
if (isSpecial && !string.IsNullOrEmpty(item.Items[0].Episode)){
|
||||
newKey = item.Items[0].Episode ?? "SP" + (specialIndex + " " + item.Items[0].Id);
|
||||
} else{
|
||||
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + item.Items[0].Id) : epIndex + "")}";
|
||||
}
|
||||
|
||||
episodes.Remove(key);
|
||||
episodes.Add(newKey, item);
|
||||
|
||||
if (isSpecial){
|
||||
specialIndex++;
|
||||
} else{
|
||||
epIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
var specials = episodes.Where(e => e.Key.StartsWith("S")).ToList();
|
||||
var normal = episodes.Where(e => e.Key.StartsWith("E")).ToList();
|
||||
|
||||
// Combining and sorting episodes with normal first, then specials.
|
||||
var sortedEpisodes = new Dictionary<string, EpisodeAndLanguage>(normal.Concat(specials));
|
||||
|
||||
foreach (var kvp in sortedEpisodes){
|
||||
var key = kvp.Key;
|
||||
var item = kvp.Value;
|
||||
|
||||
var seasonTitle = item.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)")).SeasonTitle
|
||||
?? Regex.Replace(item.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
|
||||
|
||||
var title = item.Items[0].Title;
|
||||
var seasonNumber = item.Items[0].SeasonNumber;
|
||||
|
||||
var languages = item.Items.Select((a, index) =>
|
||||
$"{(a.IsPremiumOnly ? "+ " : "")}{item.Langs.ElementAtOrDefault(index).Name ?? "Unknown"}").ToArray(); //☆
|
||||
|
||||
Console.WriteLine($"[{key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]");
|
||||
}
|
||||
|
||||
if (!serieshasversions){
|
||||
Console.WriteLine("Couldn\'t find versions on some episodes, fell back to old method.");
|
||||
}
|
||||
|
||||
CrunchySeriesList crunchySeriesList = new CrunchySeriesList();
|
||||
crunchySeriesList.Data = sortedEpisodes;
|
||||
|
||||
crunchySeriesList.List = sortedEpisodes.Select(kvp => {
|
||||
var key = kvp.Key;
|
||||
var value = kvp.Value;
|
||||
var images = (value.Items[0].Images?.Thumbnail ?? new List<List<Image>>{ new List<Image>{ new Image{ Source = "/notFound.png" } } });
|
||||
var seconds = (int)Math.Floor(value.Items[0].DurationMs / 1000.0);
|
||||
return new Episode{
|
||||
E = key.StartsWith("E") ? key.Substring(1) : key,
|
||||
Lang = value.Langs.Select(a => a.Code).ToList(),
|
||||
Name = value.Items[0].Title,
|
||||
Season = value.Items[0].SeasonNumber.ToString(),
|
||||
SeriesTitle = Regex.Replace(value.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd(),
|
||||
SeasonTitle = Regex.Replace(value.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(),
|
||||
EpisodeNum = value.Items[0].EpisodeNumber?.ToString() ?? value.Items[0].Episode ?? "?",
|
||||
Id = value.Items[0].SeasonId,
|
||||
Img = images[images.Count / 2].FirstOrDefault().Source,
|
||||
Description = value.Items[0].Description,
|
||||
Time = $"{seconds / 60}:{seconds % 60:D2}" // Ensures two digits for seconds.
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
return crunchySeriesList;
|
||||
}
|
||||
|
||||
public async Task<CrunchyEpisodeList> GetSeasonDataById(SeriesSearchItem item, bool log = false){
|
||||
CrunchyEpisodeList episodeList = new CrunchyEpisodeList(){ Data = new List<CrunchyEpisode>(), Total = 0, Meta = new Meta() };
|
||||
|
||||
if (crunInstance.CmsToken?.Cms == null){
|
||||
Console.WriteLine("Missing CMS Token");
|
||||
return episodeList;
|
||||
}
|
||||
|
||||
if (log){
|
||||
var showRequest = HttpClientReq.CreateRequestMessage($"{Api.Cms}/seasons/{item.Id}?preferred_audio_language=ja-JP", HttpMethod.Get, true, true, null);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(showRequest);
|
||||
|
||||
if (!response.IsOk){
|
||||
Console.WriteLine("Show Request FAILED!");
|
||||
} else{
|
||||
Console.WriteLine(response.ResponseContent);
|
||||
}
|
||||
}
|
||||
|
||||
//TODO
|
||||
|
||||
var episodeRequest = new HttpRequestMessage(HttpMethod.Get, $"{Api.Cms}/seasons/{item.Id}/episodes?preferred_audio_language=ja-JP");
|
||||
|
||||
episodeRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", crunInstance.Token?.access_token);
|
||||
|
||||
var episodeRequestResponse = await HttpClientReq.Instance.SendHttpRequest(episodeRequest);
|
||||
|
||||
if (!episodeRequestResponse.IsOk){
|
||||
Console.WriteLine($"Episode List Request FAILED! uri: {episodeRequest.RequestUri}");
|
||||
} else{
|
||||
episodeList = Helpers.Deserialize<CrunchyEpisodeList>(episodeRequestResponse.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
|
||||
}
|
||||
|
||||
if (episodeList.Total < 1){
|
||||
Console.WriteLine("Season is empty!");
|
||||
}
|
||||
|
||||
return episodeList;
|
||||
}
|
||||
|
||||
public Dictionary<int, Dictionary<string, SeriesSearchItem>> ParseSeriesResult(CrSeriesSearch seasonsList){
|
||||
var ret = new Dictionary<int, Dictionary<string, SeriesSearchItem>>();
|
||||
int i = 0;
|
||||
|
||||
foreach (var item in seasonsList.Data){
|
||||
i++;
|
||||
foreach (var lang in Languages.languages){
|
||||
int seasonNumber = item.SeasonNumber;
|
||||
if (item.Versions != null){
|
||||
seasonNumber = i;
|
||||
}
|
||||
|
||||
if (!ret.ContainsKey(seasonNumber)){
|
||||
ret[seasonNumber] = new Dictionary<string, SeriesSearchItem>();
|
||||
}
|
||||
|
||||
if (item.Title.Contains($"({lang.Name} Dub)") || item.Title.Contains($"({lang.Name})")){
|
||||
ret[seasonNumber][lang.Code] = item;
|
||||
} else if (item.IsSubbed && !item.IsDubbed && lang.Code == "jpn"){
|
||||
ret[seasonNumber][lang.Code] = item;
|
||||
} else if (item.IsDubbed && lang.Code == "eng" && !Languages.languages.Any(a => (item.Title.Contains($"({a.Name})") || item.Title.Contains($"({a.Name} Dub)")))){
|
||||
// Dubbed with no more infos will be treated as eng dubs
|
||||
ret[seasonNumber][lang.Code] = item;
|
||||
} else if (item.AudioLocale == lang.CrLocale){
|
||||
ret[seasonNumber][lang.Code] = item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
public async Task<CrSeriesSearch?> ParseSeriesById(string id,string? locale){
|
||||
if (crunInstance.CmsToken?.Cms == null){
|
||||
Console.WriteLine("Missing CMS Access Token");
|
||||
return null;
|
||||
}
|
||||
|
||||
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
|
||||
|
||||
query["preferred_audio_language"] = "ja-JP";
|
||||
if (!string.IsNullOrEmpty(locale)){
|
||||
query["locale"] = Languages.Locale2language(locale).CrLocale;
|
||||
}
|
||||
|
||||
|
||||
var request = HttpClientReq.CreateRequestMessage($"{Api.Cms}/series/{id}/seasons", HttpMethod.Get, true, true, query);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
||||
if (!response.IsOk){
|
||||
Console.WriteLine("Series Request Failed");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
CrSeriesSearch? seasonsList = Helpers.Deserialize<CrSeriesSearch>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
|
||||
|
||||
if (seasonsList == null || seasonsList.Total < 1){
|
||||
return null;
|
||||
}
|
||||
|
||||
return seasonsList;
|
||||
}
|
||||
|
||||
public async Task<CrSeriesBase?> SeriesById(string id){
|
||||
if (crunInstance.CmsToken?.Cms == null){
|
||||
Console.WriteLine("Missing CMS Access Token");
|
||||
return null;
|
||||
}
|
||||
|
||||
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
|
||||
|
||||
query["preferred_audio_language"] = "ja-JP";
|
||||
|
||||
var request = HttpClientReq.CreateRequestMessage($"{Api.Cms}/series/{id}", HttpMethod.Get, true, true, query);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(request);
|
||||
|
||||
if (!response.IsOk){
|
||||
Console.WriteLine("Series Request Failed");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
CrSeriesBase? series = Helpers.Deserialize<CrSeriesBase>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
|
||||
|
||||
if (series == null || series.Total < 1){
|
||||
return null;
|
||||
}
|
||||
|
||||
return series;
|
||||
}
|
||||
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,411 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Views;
|
||||
using Newtonsoft.Json;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace CRD.Downloader;
|
||||
|
||||
public class History(Crunchyroll crunInstance){
|
||||
public async Task UpdateSeries(string seriesId, string? seasonId){
|
||||
await crunInstance.CrAuth.RefreshToken(true);
|
||||
|
||||
CrSeriesSearch? parsedSeries = await crunInstance.CrSeries.ParseSeriesById(seriesId, "ja");
|
||||
|
||||
if (parsedSeries == null){
|
||||
Console.WriteLine("Parse Data Invalid");
|
||||
return;
|
||||
}
|
||||
|
||||
var result = crunInstance.CrSeries.ParseSeriesResult(parsedSeries);
|
||||
Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
|
||||
|
||||
foreach (int season in result.Keys){
|
||||
foreach (var key in result[season].Keys){
|
||||
var s = result[season][key];
|
||||
if (seasonId != null && s.Id != seasonId) continue;
|
||||
var seasonData = await crunInstance.CrSeries.GetSeasonDataById(s);
|
||||
UpdateWithSeasonData(seasonData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateHistoryFile(){
|
||||
CfgManager.WriteJsonToFile(CfgManager.PathCrHistory, crunInstance.HistoryList);
|
||||
}
|
||||
|
||||
public void SetAsDownloaded(string? seriesId, string? seasonId, string episodeId){
|
||||
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||
|
||||
if (historySeries != null){
|
||||
var historySeason = historySeries.Seasons.Find(s => s.SeasonId == seasonId);
|
||||
|
||||
if (historySeason != null){
|
||||
var historyEpisode = historySeason.EpisodesList.Find(e => e.EpisodeId == episodeId);
|
||||
|
||||
if (historyEpisode != null){
|
||||
historyEpisode.WasDownloaded = true;
|
||||
historySeason.UpdateDownloaded();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't update download History", ToastType.Warning, 1));
|
||||
}
|
||||
|
||||
|
||||
public async void UpdateWithEpisode(CrunchyEpisode episodeParam){
|
||||
var episode = episodeParam;
|
||||
|
||||
if (episode.Versions != null){
|
||||
var version = episode.Versions.Find(a => a.Original);
|
||||
if (version.AudioLocale != episode.AudioLocale){
|
||||
var episodeById = await crunInstance.CrEpisode.ParseEpisodeById(version.Guid, "");
|
||||
if (episodeById?.Data != null){
|
||||
if (episodeById.Value.Total != 1){
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't update download History", ToastType.Warning, 1));
|
||||
return;
|
||||
}
|
||||
|
||||
episode = episodeById.Value.Data.First();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var seriesId = episode.SeriesId;
|
||||
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||
if (historySeries != null){
|
||||
var historySeason = historySeries.Seasons.Find(s => s.SeasonId == episode.SeasonId);
|
||||
|
||||
if (historySeason != null){
|
||||
if (historySeason.EpisodesList.All(e => e.EpisodeId != episode.Id)){
|
||||
var newHistoryEpisode = new HistoryEpisode{
|
||||
EpisodeTitle = episode.Title,
|
||||
EpisodeId = episode.Id,
|
||||
Episode = episode.Episode,
|
||||
};
|
||||
|
||||
historySeason.EpisodesList.Add(newHistoryEpisode);
|
||||
|
||||
historySeason.EpisodesList.Sort(new NumericStringPropertyComparer());
|
||||
}
|
||||
} else{
|
||||
var newSeason = NewHistorySeason(episode);
|
||||
|
||||
historySeries.Seasons.Add(newSeason);
|
||||
|
||||
historySeries.Seasons = historySeries.Seasons.OrderBy(s => s.SeasonNum).ToList();
|
||||
}
|
||||
historySeries.UpdateNewEpisodes();
|
||||
} else{
|
||||
var newHistorySeries = new HistorySeries{
|
||||
SeriesTitle = episode.SeriesTitle,
|
||||
SeriesId = episode.SeriesId,
|
||||
Seasons =[],
|
||||
};
|
||||
crunInstance.HistoryList.Add(newHistorySeries);
|
||||
var newSeason = NewHistorySeason(episode);
|
||||
|
||||
var series = await crunInstance.CrSeries.SeriesById(seriesId);
|
||||
if (series?.Data != null){
|
||||
newHistorySeries.SeriesDescription = series.Data.First().Description;
|
||||
newHistorySeries.ThumbnailImageUrl = GetSeriesThumbnail(series);
|
||||
}
|
||||
|
||||
newHistorySeries.Seasons.Add(newSeason);
|
||||
newHistorySeries.UpdateNewEpisodes();
|
||||
}
|
||||
|
||||
var sortedList = crunInstance.HistoryList.OrderBy(item => item.SeriesTitle).ToList();
|
||||
crunInstance.HistoryList.Clear();
|
||||
foreach (var item in sortedList){
|
||||
crunInstance.HistoryList.Add(item);
|
||||
}
|
||||
|
||||
UpdateHistoryFile();
|
||||
}
|
||||
|
||||
public async void UpdateWithSeasonData(CrunchyEpisodeList seasonData){
|
||||
if (seasonData.Data != null){
|
||||
var firstEpisode = seasonData.Data.First();
|
||||
var seriesId = firstEpisode.SeriesId;
|
||||
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
|
||||
if (historySeries != null){
|
||||
var historySeason = historySeries.Seasons.Find(s => s.SeasonId == firstEpisode.SeasonId);
|
||||
|
||||
if (historySeason != null){
|
||||
foreach (var crunchyEpisode in seasonData.Data){
|
||||
if (historySeason.EpisodesList.All(e => e.EpisodeId != crunchyEpisode.Id)){
|
||||
var newHistoryEpisode = new HistoryEpisode{
|
||||
EpisodeTitle = crunchyEpisode.Title,
|
||||
EpisodeId = crunchyEpisode.Id,
|
||||
Episode = crunchyEpisode.Episode,
|
||||
};
|
||||
|
||||
historySeason.EpisodesList.Add(newHistoryEpisode);
|
||||
}
|
||||
}
|
||||
|
||||
historySeason.EpisodesList.Sort(new NumericStringPropertyComparer());
|
||||
} else{
|
||||
var newSeason = NewHistorySeason(seasonData, firstEpisode);
|
||||
|
||||
newSeason.EpisodesList.Sort(new NumericStringPropertyComparer());
|
||||
|
||||
historySeries.Seasons.Add(newSeason);
|
||||
|
||||
historySeries.Seasons = historySeries.Seasons.OrderBy(s => s.SeasonNum).ToList();
|
||||
}
|
||||
historySeries.UpdateNewEpisodes();
|
||||
} else{
|
||||
var newHistorySeries = new HistorySeries{
|
||||
SeriesTitle = firstEpisode.SeriesTitle,
|
||||
SeriesId = firstEpisode.SeriesId,
|
||||
Seasons =[],
|
||||
};
|
||||
crunInstance.HistoryList.Add(newHistorySeries);
|
||||
|
||||
var newSeason = NewHistorySeason(seasonData, firstEpisode);
|
||||
|
||||
newSeason.EpisodesList.Sort(new NumericStringPropertyComparer());
|
||||
|
||||
var series = await crunInstance.CrSeries.SeriesById(seriesId);
|
||||
if (series?.Data != null){
|
||||
newHistorySeries.SeriesDescription = series.Data.First().Description;
|
||||
newHistorySeries.ThumbnailImageUrl = GetSeriesThumbnail(series);
|
||||
}
|
||||
|
||||
|
||||
newHistorySeries.Seasons.Add(newSeason);
|
||||
newHistorySeries.UpdateNewEpisodes();
|
||||
}
|
||||
}
|
||||
|
||||
var sortedList = crunInstance.HistoryList.OrderBy(item => item.SeriesTitle).ToList();
|
||||
crunInstance.HistoryList.Clear();
|
||||
foreach (var item in sortedList){
|
||||
crunInstance.HistoryList.Add(item);
|
||||
}
|
||||
|
||||
UpdateHistoryFile();
|
||||
}
|
||||
|
||||
private string GetSeriesThumbnail(CrSeriesBase series){
|
||||
// var series = await crunInstance.CrSeries.SeriesById(seriesId);
|
||||
|
||||
if ((series.Data ?? Array.Empty<SeriesBaseItem>()).First().Images.PosterTall?.Count > 0){
|
||||
return series.Data.First().Images.PosterTall.First().First(e => e.Height == 360).Source;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private static bool CheckStringForSpecial(string identifier){
|
||||
// Regex pattern to match any sequence that does NOT contain "|S" followed by one or more digits immediately after
|
||||
string pattern = @"^(?!.*\|S\d+).*";
|
||||
|
||||
// Use Regex.IsMatch to check if the identifier matches the pattern
|
||||
return Regex.IsMatch(identifier, pattern);
|
||||
}
|
||||
|
||||
private static HistorySeason NewHistorySeason(CrunchyEpisodeList seasonData, CrunchyEpisode firstEpisode){
|
||||
var newSeason = new HistorySeason{
|
||||
SeasonTitle = firstEpisode.SeasonTitle,
|
||||
SeasonId = firstEpisode.SeasonId,
|
||||
SeasonNum = firstEpisode.SeasonNumber + "",
|
||||
EpisodesList =[],
|
||||
SpecialSeason = CheckStringForSpecial(firstEpisode.Identifier)
|
||||
};
|
||||
|
||||
foreach (var crunchyEpisode in seasonData.Data!){
|
||||
var newHistoryEpisode = new HistoryEpisode{
|
||||
EpisodeTitle = crunchyEpisode.Title,
|
||||
EpisodeId = crunchyEpisode.Id,
|
||||
Episode = crunchyEpisode.Episode,
|
||||
};
|
||||
|
||||
newSeason.EpisodesList.Add(newHistoryEpisode);
|
||||
}
|
||||
|
||||
return newSeason;
|
||||
}
|
||||
|
||||
private static HistorySeason NewHistorySeason(CrunchyEpisode episode){
|
||||
var newSeason = new HistorySeason{
|
||||
SeasonTitle = episode.SeasonTitle,
|
||||
SeasonId = episode.SeasonId,
|
||||
SeasonNum = episode.SeasonNumber + "",
|
||||
EpisodesList =[],
|
||||
};
|
||||
|
||||
var newHistoryEpisode = new HistoryEpisode{
|
||||
EpisodeTitle = episode.Title,
|
||||
EpisodeId = episode.Id,
|
||||
Episode = episode.Episode,
|
||||
};
|
||||
|
||||
newSeason.EpisodesList.Add(newHistoryEpisode);
|
||||
|
||||
|
||||
return newSeason;
|
||||
}
|
||||
}
|
||||
|
||||
public class NumericStringPropertyComparer : IComparer<HistoryEpisode>{
|
||||
public int Compare(HistoryEpisode x, HistoryEpisode y){
|
||||
if (int.TryParse(x.Episode, out int xInt) && int.TryParse(y.Episode, out int yInt)){
|
||||
return xInt.CompareTo(yInt);
|
||||
}
|
||||
|
||||
// Fall back to string comparison if not parseable as integers
|
||||
return String.Compare(x.Episode, y.Episode, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
public class HistorySeries : INotifyPropertyChanged{
|
||||
[JsonProperty("series_title")]
|
||||
public string? SeriesTitle{ get; set; }
|
||||
|
||||
[JsonProperty("series_id")]
|
||||
public string? SeriesId{ get; set; }
|
||||
|
||||
[JsonProperty("series_description")]
|
||||
public string? SeriesDescription{ get; set; }
|
||||
|
||||
[JsonProperty("series_thumbnail_url")]
|
||||
public string? ThumbnailImageUrl{ get; set; }
|
||||
|
||||
[JsonProperty("series_new_episodes")]
|
||||
public int NewEpisodes{ get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public Bitmap? ThumbnailImage{ get; set; }
|
||||
|
||||
[JsonProperty("series_season_list")]
|
||||
public required List<HistorySeason> Seasons{ get; set; }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public async Task LoadImage(){
|
||||
try{
|
||||
using (var client = new HttpClient()){
|
||||
var response = await client.GetAsync(ThumbnailImageUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using (var stream = await response.Content.ReadAsStreamAsync()){
|
||||
ThumbnailImage = new Bitmap(stream);
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ThumbnailImage)));
|
||||
}
|
||||
}
|
||||
} catch (Exception ex){
|
||||
// Handle exceptions
|
||||
Console.WriteLine("Failed to load image: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public void UpdateNewEpisodes(){
|
||||
int count = 0;
|
||||
bool foundWatched = false;
|
||||
|
||||
// Iterate over the Seasons list from the end to the beginning
|
||||
for (int i = Seasons.Count - 1; i >= 0 && !foundWatched; i--){
|
||||
|
||||
if (Seasons[i].SpecialSeason == true){
|
||||
continue;
|
||||
}
|
||||
|
||||
// Iterate over the Episodes from the end to the beginning
|
||||
for (int j = Seasons[i].EpisodesList.Count - 1; j >= 0 && !foundWatched; j--){
|
||||
if (!Seasons[i].EpisodesList[j].WasDownloaded){
|
||||
count++;
|
||||
} else{
|
||||
foundWatched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
NewEpisodes = count;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NewEpisodes)));
|
||||
}
|
||||
|
||||
public async Task FetchData(string? seasonId){
|
||||
await Crunchyroll.Instance.CrHistory.UpdateSeries(SeriesId, seasonId);
|
||||
}
|
||||
}
|
||||
|
||||
public class HistorySeason : INotifyPropertyChanged{
|
||||
[JsonProperty("season_title")]
|
||||
public string? SeasonTitle{ get; set; }
|
||||
|
||||
[JsonProperty("season_id")]
|
||||
public string? SeasonId{ get; set; }
|
||||
|
||||
[JsonProperty("season_cr_season_number")]
|
||||
public string? SeasonNum{ get; set; }
|
||||
|
||||
[JsonProperty("season_special_season")]
|
||||
public bool? SpecialSeason{ get; set; }
|
||||
[JsonIgnore]
|
||||
public string CombinedProperty => SpecialSeason ?? false ? $"Specials {SeasonNum}" : $"Season {SeasonNum}";
|
||||
|
||||
[JsonProperty("season_downloaded_episodes")]
|
||||
public int DownloadedEpisodes{ get; set; }
|
||||
|
||||
[JsonProperty("season_episode_list")]
|
||||
public required List<HistoryEpisode> EpisodesList{ get; set; }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public void UpdateDownloaded(string? EpisodeId){
|
||||
if (!string.IsNullOrEmpty(EpisodeId)){
|
||||
EpisodesList.First(e => e.EpisodeId == EpisodeId).ToggleWasDownloaded();
|
||||
}
|
||||
|
||||
DownloadedEpisodes = EpisodesList.FindAll(e => e.WasDownloaded).Count;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadedEpisodes)));
|
||||
CfgManager.WriteJsonToFile(CfgManager.PathCrHistory, Crunchyroll.Instance.HistoryList);
|
||||
}
|
||||
|
||||
public void UpdateDownloaded(){
|
||||
DownloadedEpisodes = EpisodesList.FindAll(e => e.WasDownloaded).Count;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadedEpisodes)));
|
||||
CfgManager.WriteJsonToFile(CfgManager.PathCrHistory, Crunchyroll.Instance.HistoryList);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class HistoryEpisode : INotifyPropertyChanged{
|
||||
[JsonProperty("episode_title")]
|
||||
public string? EpisodeTitle{ get; set; }
|
||||
|
||||
[JsonProperty("episode_id")]
|
||||
public string? EpisodeId{ get; set; }
|
||||
|
||||
[JsonProperty("episode_cr_episode_number")]
|
||||
public string? Episode{ get; set; }
|
||||
|
||||
[JsonProperty("episode_was_downloaded")]
|
||||
public bool WasDownloaded{ get; set; }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public void ToggleWasDownloaded(){
|
||||
WasDownloaded = !WasDownloaded;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(WasDownloaded)));
|
||||
}
|
||||
|
||||
public void DownloadEpisode(){
|
||||
Crunchyroll.Instance.AddEpisodeToQue(EpisodeId, Crunchyroll.Instance.DefaultLocale, Crunchyroll.Instance.CrunOptions.DubLang);
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
using System;
|
||||
using Avalonia;
|
||||
|
||||
namespace CRD;
|
||||
|
||||
sealed class Program{
|
||||
// Initialization code. Don't use any Avalonia, third-party APIs or any
|
||||
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
|
||||
// yet and stuff might break.
|
||||
[STAThread]
|
||||
public static void Main(string[] args) => BuildAvaloniaApp()
|
||||
.StartWithClassicDesktopLifetime(args);
|
||||
|
||||
// Avalonia configuration, don't remove; also used by visual designer.
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.WithInterFont()
|
||||
.LogToTrace();
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
<Styles xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:uip="using:FluentAvalonia.UI.Controls.Primitives">
|
||||
|
||||
|
||||
|
||||
<!--
|
||||
NavView style in MainView for main app navigation
|
||||
While you are free to copy this into your own apps
|
||||
if you want an MS store like NavView, this will NOT
|
||||
be an officially supported thing in the main library
|
||||
-->
|
||||
<Style Selector="ui|NavigationView.SampleAppNav">
|
||||
<Setter Property="IsPaneToggleButtonVisible" Value="False" />
|
||||
<Setter Property="OpenPaneLength" Value="72" />
|
||||
<Setter Property="IsPaneOpen" Value="True" />
|
||||
</Style>
|
||||
<Style Selector="ui|NavigationView.SampleAppNav /template/ Button#NavigationViewBackButton">
|
||||
<Setter Property="Width" Value="{DynamicResource NavigationBackButtonWidth}" />
|
||||
</Style>
|
||||
<Style Selector="ui|NavigationView.SampleAppNav[IsBackButtonVisible=False] SplitView /template/ ContentPresenter#PART_PanePresenter">
|
||||
<Setter Property="Margin" Value="0 40 0 0" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationViewItem.SampleAppNav uip|NavigationViewItemPresenter">
|
||||
<Setter Property="Width" Value="72" />
|
||||
<Setter Property="MinHeight" Value="60" />
|
||||
<Setter Property="CornerRadius" Value="{StaticResource ControlCornerRadius}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Border Name="LayoutRoot"
|
||||
Background="{TemplateBinding Background}"
|
||||
CornerRadius="{TemplateBinding CornerRadius}"
|
||||
Margin="4 2"
|
||||
TemplatedControl.IsTemplateFocusTarget="True">
|
||||
<Panel>
|
||||
<Panel HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center">
|
||||
|
||||
<Border Name="SelectionIndicator"
|
||||
Background="{DynamicResource NavigationViewSelectionIndicatorForeground}"
|
||||
Width="3"
|
||||
Opacity="0"
|
||||
VerticalAlignment="Center"
|
||||
Height="20"
|
||||
CornerRadius="{StaticResource ControlCornerRadius}"/>
|
||||
</Panel>
|
||||
|
||||
|
||||
<DockPanel>
|
||||
<ContentPresenter Name="ContentPresenter"
|
||||
Grid.Row="1"
|
||||
ContentTemplate="{TemplateBinding ContentTemplate}"
|
||||
Content="{TemplateBinding Content}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Bottom"
|
||||
FontSize="10"
|
||||
Padding="0 4"
|
||||
Margin="0 -15 0 3"
|
||||
DockPanel.Dock="Bottom"
|
||||
IsVisible="False">
|
||||
<ContentPresenter.Styles>
|
||||
<Style Selector="TextBlock">
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
</ContentPresenter.Styles>
|
||||
</ContentPresenter>
|
||||
|
||||
<Viewbox Name="IconBox"
|
||||
Height="28"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center">
|
||||
<ContentPresenter Name="Icon"
|
||||
Content="{Binding TemplateSettings.Icon, RelativeSource={RelativeSource TemplatedParent}}" />
|
||||
</Viewbox>
|
||||
|
||||
</DockPanel>
|
||||
</Panel>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ui|NavigationViewItem.SampleAppNav uip|NavigationViewItemPresenter:pointerover /template/ ContentPresenter#ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ui|NavigationViewItem.SampleAppNav uip|NavigationViewItemPresenter:pointerover /template/ ContentPresenter#Icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationViewItem.SampleAppNav uip|NavigationViewItemPresenter:pressed /template/ ContentPresenter#ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</Style>
|
||||
<Style Selector="ui|NavigationViewItem.SampleAppNav uip|NavigationViewItemPresenter:pressed /template/ ContentPresenter#Icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ui|NavigationViewItem.SampleAppNav uip|NavigationViewItemPresenter:selected /template/ ContentPresenter#ContentPresenter">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="ui|NavigationViewItem.SampleAppNav uip|NavigationViewItemPresenter:selected /template/ ContentPresenter#Icon">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AccentFillColorDefaultBrush}" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
|
@ -0,0 +1,10 @@
|
|||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
|
||||
namespace CRD.Utils.CustomList;
|
||||
|
||||
public class RefreshableObservableCollection<T> : ObservableCollection<T>{
|
||||
public void Refresh(){
|
||||
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace CRD.Utils.DRM;
|
||||
|
||||
[Serializable]
|
||||
public class ContentKey{
|
||||
[JsonPropertyName("key_id")] public byte[] KeyID{ get; set; }
|
||||
|
||||
[JsonPropertyName("type")] public string Type{ get; set; }
|
||||
|
||||
[JsonPropertyName("bytes")] public byte[] Bytes{ get; set; } // key
|
||||
|
||||
[NotMapped]
|
||||
[JsonPropertyName("permissions")]
|
||||
public List<string> Permissions{
|
||||
get{ return PermissionsString.Split(",").ToList(); }
|
||||
set{ PermissionsString = string.Join(",", value); }
|
||||
}
|
||||
|
||||
[JsonIgnore] public string PermissionsString{ get; set; }
|
||||
|
||||
public override string ToString(){
|
||||
return $"{BitConverter.ToString(KeyID).Replace("-", "").ToLower()}:{BitConverter.ToString(Bytes).Replace("-", "").ToLower()}";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
namespace CRD.Utils.DRM;
|
||||
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Engines;
|
||||
using Org.BouncyCastle.Crypto.Macs;
|
||||
using Org.BouncyCastle.Crypto.Parameters;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
public class CryptoUtils{
|
||||
public static byte[] GetHMACSHA256Digest(byte[] data, byte[] key){
|
||||
return new HMACSHA256(key).ComputeHash(data);
|
||||
}
|
||||
|
||||
public static byte[] GetCMACDigest(byte[] data, byte[] key){
|
||||
IBlockCipher cipher = new AesEngine();
|
||||
IMac mac = new CMac(cipher, 128);
|
||||
|
||||
KeyParameter keyParam = new KeyParameter(key);
|
||||
|
||||
mac.Init(keyParam);
|
||||
|
||||
mac.BlockUpdate(data, 0, data.Length);
|
||||
|
||||
byte[] outBytes = new byte[16];
|
||||
|
||||
mac.DoFinal(outBytes, 0);
|
||||
return outBytes;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
namespace CRD.Utils.DRM;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
class PSSHBox{
|
||||
static readonly byte[] PSSH_HEADER = new byte[]{ 0x70, 0x73, 0x73, 0x68 };
|
||||
|
||||
public List<byte[]> KIDs{ get; set; } = new List<byte[]>();
|
||||
public byte[] Data{ get; set; }
|
||||
|
||||
PSSHBox(List<byte[]> kids, byte[] data){
|
||||
KIDs = kids;
|
||||
Data = data;
|
||||
}
|
||||
|
||||
public static PSSHBox FromByteArray(byte[] psshbox){
|
||||
using var stream = new System.IO.MemoryStream(psshbox);
|
||||
|
||||
stream.Seek(4, System.IO.SeekOrigin.Current);
|
||||
byte[] header = new byte[4];
|
||||
stream.Read(header, 0, 4);
|
||||
|
||||
if (!header.SequenceEqual(PSSH_HEADER))
|
||||
throw new Exception("Not a pssh box");
|
||||
|
||||
stream.Seek(20, System.IO.SeekOrigin.Current);
|
||||
byte[] kidCountBytes = new byte[4];
|
||||
stream.Read(kidCountBytes, 0, 4);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(kidCountBytes);
|
||||
uint kidCount = BitConverter.ToUInt32(kidCountBytes);
|
||||
|
||||
List<byte[]> kids = new List<byte[]>();
|
||||
for (int i = 0; i < kidCount; i++){
|
||||
byte[] kid = new byte[16];
|
||||
stream.Read(kid);
|
||||
kids.Add(kid);
|
||||
}
|
||||
|
||||
byte[] dataLengthBytes = new byte[4];
|
||||
stream.Read(dataLengthBytes);
|
||||
|
||||
if (BitConverter.IsLittleEndian)
|
||||
Array.Reverse(dataLengthBytes);
|
||||
uint dataLength = BitConverter.ToUInt32(dataLengthBytes);
|
||||
|
||||
if (dataLength == 0)
|
||||
return new PSSHBox(kids, null);
|
||||
|
||||
byte[] data = new byte[dataLength];
|
||||
stream.Read(data);
|
||||
|
||||
return new PSSHBox(kids, data);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
// using System;
|
||||
// using System.Collections.Generic;
|
||||
// using System.IO;
|
||||
// using ProtoBuf;
|
||||
//
|
||||
// namespace CRD.Utils.DRM;
|
||||
//
|
||||
// public class ClientIdentification{
|
||||
// /** Type of factory-provisioned device root of trust. Optional. */
|
||||
// public ClientIdentification_TokenType type{ get; set; }
|
||||
//
|
||||
// /** Factory-provisioned device root of trust. Required. */
|
||||
// public byte[] token{ get; set; }
|
||||
//
|
||||
// /** Optional client information name/value pairs. */
|
||||
// public List<ClientIdentification_NameValue> clientInfo{ get; set; }
|
||||
//
|
||||
// /** Client token generated by the content provider. Optional. */
|
||||
// public byte[] providerClientToken{ get; set; }
|
||||
//
|
||||
// /**
|
||||
// * Number of licenses received by the client to which the token above belongs.
|
||||
// * Only present if client_token is specified.
|
||||
// */
|
||||
// public double licenseCounter{ get; set; }
|
||||
//
|
||||
// /** List of non-baseline client capabilities. */
|
||||
// public ClientIdentification_ClientCapabilities? clientCapabilities{ get; set; }
|
||||
//
|
||||
// /** Serialized VmpData message. Optional. */
|
||||
// public byte[] vmpData{ get; set; }
|
||||
//
|
||||
// /** Optional field that may contain additional provisioning credentials. */
|
||||
// public List<ClientIdentification_ClientCredentials> deviceCredentials{ get; set; }
|
||||
//
|
||||
// public static ClientIdentification decode(byte[] input){
|
||||
// return Serializer.Deserialize<ClientIdentification>(new MemoryStream(input));
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public struct ClientIdentification_NameValue{
|
||||
// public string name{ get; set; }
|
||||
// public string value{ get; set; }
|
||||
// }
|
||||
//
|
||||
// public enum ClientIdentification_TokenType{
|
||||
// KEYBOX = 0,
|
||||
// DRM_DEVICE_CERTIFICATE = 1,
|
||||
// REMOTE_ATTESTATION_CERTIFICATE = 2,
|
||||
// OEM_DEVICE_CERTIFICATE = 3,
|
||||
// UNRECOGNIZED = -1
|
||||
// }
|
||||
//
|
||||
// public struct ClientIdentification_ClientCredentials{
|
||||
// public ClientIdentification_TokenType type{ get; set; }
|
||||
// public byte[] token{ get; set; }
|
||||
// }
|
||||
//
|
||||
// /**
|
||||
// * Capabilities which not all clients may support. Used for the license
|
||||
// * exchange protocol only.
|
||||
// */
|
||||
// public class ClientIdentification_ClientCapabilities{
|
||||
// public bool clientToken{ get; set; }
|
||||
// public bool sessionToken{ get; set; }
|
||||
// public bool videoResolutionConstraints{ get; set; }
|
||||
// public ClientIdentification_ClientCapabilities_HdcpVersion maxHdcpVersion{ get; set; }
|
||||
// public double oemCryptoApiVersion{ get; set; }
|
||||
//
|
||||
// /**
|
||||
// * Client has hardware support for protecting the usage table, such as
|
||||
// * storing the generation number in secure memory. For Details, see:
|
||||
// * Widevine Modular DRM Security Integration Guide for CENC
|
||||
// */
|
||||
// public bool antiRollbackUsageTable{ get; set; }
|
||||
//
|
||||
// /** The client shall report |srm_version| if available. */
|
||||
// public double srmVersion{ get; set; }
|
||||
//
|
||||
// /**
|
||||
// * A device may have SRM data, and report a version, but may not be capable
|
||||
// * of updating SRM data.
|
||||
// */
|
||||
// public bool canUpdateSrm{ get; set; }
|
||||
//
|
||||
// public ClientIdentification_ClientCapabilities_CertificateKeyType[] supportedCertificateKeyType{ get; set; }
|
||||
// public ClientIdentification_ClientCapabilities_AnalogOutputCapabilities analogOutputCapabilities{ get; set; }
|
||||
// public bool canDisableAnalogOutput{ get; set; }
|
||||
//
|
||||
// /**
|
||||
// * Clients can indicate a performance level supported by OEMCrypto.
|
||||
// * This will allow applications and providers to choose an appropriate
|
||||
// * quality of content to serve. Currently defined tiers are
|
||||
// * 1 (low), 2 (medium) and 3 (high). Any other value indicates that
|
||||
// * the resource rating is unavailable or reporting erroneous values
|
||||
// * for that device. For details see,
|
||||
// * Widevine Modular DRM Security Integration Guide for CENC
|
||||
// */
|
||||
// public double resourceRatingTier{ get; set; }
|
||||
// }
|
||||
//
|
||||
// public enum ClientIdentification_ClientCapabilities_HdcpVersion{
|
||||
// HDCP_NONE = 0,
|
||||
// HDCP_V1 = 1,
|
||||
// HDCP_V2 = 2,
|
||||
// HDCP_V2_1 = 3,
|
||||
// HDCP_V2_2 = 4,
|
||||
// HDCP_V2_3 = 5,
|
||||
// HDCP_NO_DIGITAL_OUTPUT = 255,
|
||||
// UNRECOGNIZED = -1
|
||||
// }
|
||||
//
|
||||
// public enum ClientIdentification_ClientCapabilities_AnalogOutputCapabilities{
|
||||
// ANALOG_OUTPUT_UNKNOWN = 0,
|
||||
// ANALOG_OUTPUT_NONE = 1,
|
||||
// ANALOG_OUTPUT_SUPPORTED = 2,
|
||||
// ANALOG_OUTPUT_SUPPORTS_CGMS_A = 3,
|
||||
// UNRECOGNIZED = -1
|
||||
// }
|
||||
//
|
||||
// public enum ClientIdentification_ClientCapabilities_CertificateKeyType{
|
||||
// RSA_2048 = 0,
|
||||
// RSA_3072 = 1,
|
||||
// ECC_SECP256R1 = 2,
|
||||
// ECC_SECP384R1 = 3,
|
||||
// ECC_SECP521R1 = 4,
|
||||
// UNRECOGNIZED = -1
|
||||
// }
|
|
@ -0,0 +1,332 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Org.BouncyCastle.Crypto;
|
||||
using Org.BouncyCastle.Crypto.Digests;
|
||||
using Org.BouncyCastle.Crypto.Encodings;
|
||||
using Org.BouncyCastle.Crypto.Engines;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
using Org.BouncyCastle.OpenSsl;
|
||||
using ProtoBuf;
|
||||
|
||||
namespace CRD.Utils.DRM;
|
||||
|
||||
public struct ContentDecryptionModule{
|
||||
public byte[] privateKey{ get; set; }
|
||||
public byte[] identifierBlob{ get; set; }
|
||||
}
|
||||
|
||||
public class DerivedKeys{
|
||||
public byte[] Auth1{ get; set; }
|
||||
public byte[] Auth2{ get; set; }
|
||||
public byte[] Enc{ get; set; }
|
||||
}
|
||||
|
||||
public class Session{
|
||||
public byte[] WIDEVINE_SYSTEM_ID = new byte[]{ 237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237 };
|
||||
|
||||
private RSA _devicePrivateKey;
|
||||
private ClientIdentification _identifierBlob;
|
||||
private byte[] _identifier;
|
||||
private byte[] _pssh;
|
||||
private byte[] _rawLicenseRequest;
|
||||
private byte[] _sessionKey;
|
||||
private DerivedKeys _derivedKeys;
|
||||
private OaepEncoding _decryptEngine;
|
||||
public List<ContentKey> ContentKeys { get; set; } = new List<ContentKey>();
|
||||
public dynamic InitData{ get; set; }
|
||||
|
||||
private AsymmetricCipherKeyPair DeviceKeys{ get; set; }
|
||||
|
||||
public Session(ContentDecryptionModule contentDecryptionModule, byte[] pssh){
|
||||
_devicePrivateKey = CreatePrivateKeyFromPem(contentDecryptionModule.privateKey);
|
||||
|
||||
using var reader = new StringReader(Encoding.UTF8.GetString(contentDecryptionModule.privateKey));
|
||||
DeviceKeys = (AsymmetricCipherKeyPair)new PemReader(reader).ReadObject();
|
||||
|
||||
_identifierBlob = Serializer.Deserialize<ClientIdentification>(new MemoryStream(contentDecryptionModule.identifierBlob));
|
||||
_identifier = GenerateIdentifier();
|
||||
_pssh = pssh;
|
||||
InitData = ParseInitData(pssh);
|
||||
_decryptEngine = new OaepEncoding(new RsaEngine());
|
||||
_decryptEngine.Init(false, DeviceKeys.Private);
|
||||
}
|
||||
|
||||
private RSA CreatePrivateKeyFromPem(byte[] pemKey){
|
||||
RSA rsa = RSA.Create();
|
||||
string s = System.Text.Encoding.UTF8.GetString(pemKey);
|
||||
rsa.ImportFromPem(s);
|
||||
return rsa;
|
||||
}
|
||||
|
||||
private byte[] GenerateIdentifier(){
|
||||
// Generate 8 random bytes
|
||||
byte[] randomBytes = RandomNumberGenerator.GetBytes(8);
|
||||
|
||||
// Convert to hex string
|
||||
string hex = BitConverter.ToString(randomBytes).Replace("-", "").ToLower();
|
||||
|
||||
// Concatenate with '01' and '00000000000000'
|
||||
string identifier = hex + "01" + "00000000000000";
|
||||
|
||||
// Convert the final string to a byte array
|
||||
return Encoding.UTF8.GetBytes(identifier);
|
||||
}
|
||||
|
||||
public byte[] GetLicenseRequest(){
|
||||
dynamic licenseRequest;
|
||||
|
||||
if (InitData is WidevineCencHeader){
|
||||
licenseRequest = new SignedLicenseRequest{
|
||||
Type = SignedLicenseRequest.MessageType.LicenseRequest,
|
||||
Msg = new LicenseRequest{
|
||||
Type = LicenseRequest.RequestType.New,
|
||||
KeyControlNonce = 1093602366,
|
||||
ProtocolVersion = ProtocolVersion.Current,
|
||||
RequestTime = uint.Parse((DateTime.Now - DateTime.UnixEpoch).TotalSeconds.ToString().Split(",")[0]),
|
||||
ContentId = new LicenseRequest.ContentIdentification{
|
||||
CencId = new LicenseRequest.ContentIdentification.Cenc{
|
||||
LicenseType = LicenseType.Default,
|
||||
RequestId = _identifier,
|
||||
Pssh = InitData
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
} else{
|
||||
licenseRequest = new SignedLicenseRequestRaw{
|
||||
Type = SignedLicenseRequestRaw.MessageType.LicenseRequest,
|
||||
Msg = new LicenseRequestRaw{
|
||||
Type = LicenseRequestRaw.RequestType.New,
|
||||
KeyControlNonce = 1093602366,
|
||||
ProtocolVersion = ProtocolVersion.Current,
|
||||
RequestTime = uint.Parse((DateTime.Now - DateTime.UnixEpoch).TotalSeconds.ToString().Split(",")[0]),
|
||||
ContentId = new LicenseRequestRaw.ContentIdentification{
|
||||
CencId = new LicenseRequestRaw.ContentIdentification.Cenc{
|
||||
LicenseType = LicenseType.Default,
|
||||
RequestId = _identifier,
|
||||
Pssh = InitData
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
licenseRequest.Msg.ClientId = _identifierBlob;
|
||||
|
||||
//Logger.Debug("Signing license request");
|
||||
|
||||
using (var memoryStream = new MemoryStream()){
|
||||
Serializer.Serialize(memoryStream, licenseRequest.Msg);
|
||||
byte[] data = memoryStream.ToArray();
|
||||
_rawLicenseRequest = data;
|
||||
|
||||
licenseRequest.Signature = Sign(data);
|
||||
}
|
||||
|
||||
byte[] requestBytes;
|
||||
using (var memoryStream = new MemoryStream()){
|
||||
Serializer.Serialize(memoryStream, licenseRequest);
|
||||
requestBytes = memoryStream.ToArray();
|
||||
}
|
||||
|
||||
return requestBytes;
|
||||
}
|
||||
|
||||
static WidevineCencHeader ParseInitData(byte[] initData){
|
||||
WidevineCencHeader cencHeader;
|
||||
|
||||
try{
|
||||
cencHeader = Serializer.Deserialize<WidevineCencHeader>(new MemoryStream(initData[32..]));
|
||||
} catch{
|
||||
try{
|
||||
//needed for HBO Max
|
||||
|
||||
PSSHBox psshBox = PSSHBox.FromByteArray(initData);
|
||||
cencHeader = Serializer.Deserialize<WidevineCencHeader>(new MemoryStream(psshBox.Data));
|
||||
} catch{
|
||||
//Logger.Verbose("Unable to parse, unsupported init data format");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return cencHeader;
|
||||
}
|
||||
|
||||
|
||||
public byte[] Sign(byte[] data){
|
||||
PssSigner eng = new PssSigner(new RsaEngine(), new Sha1Digest());
|
||||
|
||||
eng.Init(true, DeviceKeys.Private);
|
||||
eng.BlockUpdate(data, 0, data.Length);
|
||||
return eng.GenerateSignature();
|
||||
}
|
||||
|
||||
public byte[] Decrypt(byte[] data){
|
||||
int blockSize = _decryptEngine.GetInputBlockSize();
|
||||
List<byte> plainText = new List<byte>();
|
||||
|
||||
// Process the data in blocks
|
||||
for (int chunkPosition = 0; chunkPosition < data.Length; chunkPosition += blockSize){
|
||||
int chunkSize = Math.Min(blockSize, data.Length - chunkPosition);
|
||||
byte[] decryptedChunk = _decryptEngine.ProcessBlock(data, chunkPosition, chunkSize);
|
||||
plainText.AddRange(decryptedChunk);
|
||||
}
|
||||
|
||||
return plainText.ToArray();
|
||||
}
|
||||
|
||||
public void ProvideLicense(byte[] license){
|
||||
SignedLicense signedLicense;
|
||||
try{
|
||||
signedLicense = Serializer.Deserialize<SignedLicense>(new MemoryStream(license));
|
||||
} catch{
|
||||
throw new Exception("Unable to parse license");
|
||||
}
|
||||
|
||||
try{
|
||||
var sessionKey = Decrypt(signedLicense.SessionKey);
|
||||
|
||||
if (sessionKey.Length != 16){
|
||||
throw new Exception("Unable to decrypt session key");
|
||||
}
|
||||
|
||||
_sessionKey = sessionKey;
|
||||
} catch{
|
||||
throw new Exception("Unable to decrypt session key");
|
||||
}
|
||||
|
||||
_derivedKeys = DeriveKeys(_rawLicenseRequest, _sessionKey);
|
||||
|
||||
byte[] licenseBytes;
|
||||
using (var memoryStream = new MemoryStream()){
|
||||
Serializer.Serialize(memoryStream, signedLicense.Msg);
|
||||
licenseBytes = memoryStream.ToArray();
|
||||
}
|
||||
|
||||
byte[] hmacHash = CryptoUtils.GetHMACSHA256Digest(licenseBytes, _derivedKeys.Auth1);
|
||||
|
||||
if (!hmacHash.SequenceEqual(signedLicense.Signature)){
|
||||
throw new Exception("License signature mismatch");
|
||||
}
|
||||
|
||||
foreach (License.KeyContainer key in signedLicense.Msg.Keys){
|
||||
string type = key.Type.ToString();
|
||||
|
||||
if (type == "Signing")
|
||||
continue;
|
||||
|
||||
byte[] keyId;
|
||||
byte[] encryptedKey = key.Key;
|
||||
byte[] iv = key.Iv;
|
||||
keyId = key.Id;
|
||||
if (keyId == null){
|
||||
keyId = Encoding.ASCII.GetBytes(key.Type.ToString());
|
||||
}
|
||||
|
||||
byte[] decryptedKey;
|
||||
|
||||
using MemoryStream mstream = new MemoryStream();
|
||||
using AesCryptoServiceProvider aesProvider = new AesCryptoServiceProvider{
|
||||
Mode = CipherMode.CBC,
|
||||
Padding = PaddingMode.PKCS7
|
||||
};
|
||||
using CryptoStream cryptoStream = new CryptoStream(mstream, aesProvider.CreateDecryptor(_derivedKeys.Enc, iv), CryptoStreamMode.Write);
|
||||
cryptoStream.Write(encryptedKey, 0, encryptedKey.Length);
|
||||
decryptedKey = mstream.ToArray();
|
||||
|
||||
List<string> permissions = new List<string>();
|
||||
if (type == "OperatorSession"){
|
||||
foreach (PropertyInfo perm in key._OperatorSessionKeyPermissions.GetType().GetProperties()){
|
||||
if ((uint)perm.GetValue(key._OperatorSessionKeyPermissions) == 1){
|
||||
permissions.Add(perm.Name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ContentKeys.Add(new ContentKey{
|
||||
KeyID = keyId,
|
||||
Type = type,
|
||||
Bytes = decryptedKey,
|
||||
Permissions = permissions
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
public static DerivedKeys DeriveKeys(byte[] message, byte[] key){
|
||||
byte[] encKeyBase = Encoding.UTF8.GetBytes("ENCRYPTION").Concat(new byte[]{ 0x0, }).Concat(message).Concat(new byte[]{ 0x0, 0x0, 0x0, 0x80 }).ToArray();
|
||||
byte[] authKeyBase = Encoding.UTF8.GetBytes("AUTHENTICATION").Concat(new byte[]{ 0x0, }).Concat(message).Concat(new byte[]{ 0x0, 0x0, 0x2, 0x0 }).ToArray();
|
||||
|
||||
byte[] encKey = new byte[]{ 0x01 }.Concat(encKeyBase).ToArray();
|
||||
byte[] authKey1 = new byte[]{ 0x01 }.Concat(authKeyBase).ToArray();
|
||||
byte[] authKey2 = new byte[]{ 0x02 }.Concat(authKeyBase).ToArray();
|
||||
byte[] authKey3 = new byte[]{ 0x03 }.Concat(authKeyBase).ToArray();
|
||||
byte[] authKey4 = new byte[]{ 0x04 }.Concat(authKeyBase).ToArray();
|
||||
|
||||
byte[] encCmacKey = CryptoUtils.GetCMACDigest(encKey, key);
|
||||
byte[] authCmacKey1 = CryptoUtils.GetCMACDigest(authKey1, key);
|
||||
byte[] authCmacKey2 = CryptoUtils.GetCMACDigest(authKey2, key);
|
||||
byte[] authCmacKey3 = CryptoUtils.GetCMACDigest(authKey3, key);
|
||||
byte[] authCmacKey4 = CryptoUtils.GetCMACDigest(authKey4, key);
|
||||
|
||||
byte[] authCmacCombined1 = authCmacKey1.Concat(authCmacKey2).ToArray();
|
||||
byte[] authCmacCombined2 = authCmacKey3.Concat(authCmacKey4).ToArray();
|
||||
|
||||
return new DerivedKeys{
|
||||
Auth1 = authCmacCombined1,
|
||||
Auth2 = authCmacCombined2,
|
||||
Enc = encCmacKey
|
||||
};
|
||||
}
|
||||
|
||||
// public KeyContainer ParseLicense(byte[] rawLicense){
|
||||
// if (_rawLicenseRequest == null){
|
||||
// throw new InvalidOperationException("Please request a license first.");
|
||||
// }
|
||||
//
|
||||
// // Assuming SignedMessage and License have Decode methods that deserialize the respective types
|
||||
// var signedLicense = Serializer.Deserialize<SignedMessage>(new MemoryStream(rawLicense));
|
||||
// byte[] sessionKey = _devicePrivateKey.Decrypt(signedLicense.SessionKey, RSAEncryptionPadding.OaepSHA256);
|
||||
//
|
||||
// var cmac = new AesCmac(sessionKey);
|
||||
// var encKeyBase = Concat("ENCRYPTION\x00", _rawLicenseRequest, "\x00\x00\x00\x80");
|
||||
// var authKeyBase = Concat("AUTHENTICATION\x00", _rawLicenseRequest, "\x00\x00\x02\x00");
|
||||
//
|
||||
// byte[] encKey = cmac.ComputeHash(Concat("\x01", encKeyBase));
|
||||
// byte[] serverKey = Concat(
|
||||
// cmac.ComputeHash(Concat("\x01", authKeyBase)),
|
||||
// cmac.ComputeHash(Concat("\x02", authKeyBase))
|
||||
// );
|
||||
//
|
||||
// using var hmac = new HMACSHA256(serverKey);
|
||||
// byte[] calculatedSignature = hmac.ComputeHash(signedLicense.Msg);
|
||||
//
|
||||
// if (!calculatedSignature.SequenceEqual(signedLicense.Signature)){
|
||||
// throw new InvalidOperationException("Signatures do not match.");
|
||||
// }
|
||||
//
|
||||
// var license = License.Decode(signedLicense.Msg);
|
||||
//
|
||||
// return license.Key.Select(keyContainer => {
|
||||
// string keyId = keyContainer.Id.Length > 0 ? BitConverter.ToString(keyContainer.Id).Replace("-", "").ToLower() : keyContainer.Type.ToString();
|
||||
// using var aes = Aes.Create();
|
||||
// aes.Key = encKey;
|
||||
// aes.IV = keyContainer.Iv;
|
||||
// aes.Mode = CipherMode.CBC;
|
||||
//
|
||||
// using var decryptor = aes.CreateDecryptor();
|
||||
// byte[] decryptedKey = decryptor.TransformFinalBlock(keyContainer.Key, 0, keyContainer.Key.Length);
|
||||
//
|
||||
// return new KeyContainer{
|
||||
// Kid = keyId,
|
||||
// Key = BitConverter.ToString(decryptedKey).Replace("-", "").ToLower()
|
||||
// };
|
||||
// }).ToArray();
|
||||
// }
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.DRM;
|
||||
|
||||
public class Widevine{
|
||||
private byte[] privateKey = new byte[0];
|
||||
private byte[] identifierBlob = new byte[0];
|
||||
|
||||
public bool canDecrypt = false;
|
||||
|
||||
|
||||
#region Singelton
|
||||
|
||||
private static Widevine? instance;
|
||||
private static readonly object padlock = new object();
|
||||
|
||||
public static Widevine Instance{
|
||||
get{
|
||||
if (instance == null){
|
||||
lock (padlock){
|
||||
if (instance == null){
|
||||
instance = new Widevine();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public Widevine(){
|
||||
try{
|
||||
if (Directory.Exists(CfgManager.PathWIDEVINE_DIR)){
|
||||
var files = Directory.GetFiles(CfgManager.PathWIDEVINE_DIR);
|
||||
|
||||
foreach (var file in files){
|
||||
var fileInfo = new FileInfo(file);
|
||||
if (fileInfo.Length < 1024 * 8 && !fileInfo.Attributes.HasFlag(FileAttributes.Directory)){
|
||||
string fileContents = File.ReadAllText(file, Encoding.UTF8);
|
||||
if (fileContents.Contains("-BEGIN RSA PRIVATE KEY-")){
|
||||
privateKey = File.ReadAllBytes(file);
|
||||
}
|
||||
|
||||
if (fileContents.Contains("widevine_cdm_version")){
|
||||
identifierBlob = File.ReadAllBytes(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (privateKey.Length != 0 && identifierBlob.Length != 0){
|
||||
canDecrypt = true;
|
||||
} else if (privateKey.Length == 0){
|
||||
Console.WriteLine("Private key missing");
|
||||
canDecrypt = false;
|
||||
} else if (identifierBlob.Length == 0){
|
||||
Console.WriteLine("Identifier blob missing");
|
||||
canDecrypt = false;
|
||||
}
|
||||
} catch (Exception e){
|
||||
Console.WriteLine(e);
|
||||
canDecrypt = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<ContentKey>> getKeys(string? pssh, string licenseServer, Dictionary<string, string> authData){
|
||||
if (pssh == null || !canDecrypt) return new List<ContentKey>();
|
||||
|
||||
byte[] psshBuffer = Convert.FromBase64String(pssh);
|
||||
|
||||
Session ses = new Session(new ContentDecryptionModule{ identifierBlob = identifierBlob, privateKey = privateKey }, psshBuffer);
|
||||
|
||||
var playbackRequest2 = new HttpRequestMessage(HttpMethod.Post, licenseServer);
|
||||
foreach (var keyValuePair in authData){
|
||||
playbackRequest2.Headers.Add(keyValuePair.Key, keyValuePair.Value);
|
||||
}
|
||||
|
||||
var licenceReq = ses.GetLicenseRequest();
|
||||
playbackRequest2.Content = new ByteArrayContent(licenceReq);
|
||||
|
||||
var response = await HttpClientReq.Instance.SendHttpRequest(playbackRequest2);
|
||||
|
||||
if (!response.IsOk){
|
||||
Console.WriteLine("Fallback Request Stream URLs FAILED!");
|
||||
return new List<ContentKey>();
|
||||
}
|
||||
|
||||
LicenceReqResp resp = Helpers.Deserialize<LicenceReqResp>(response.ResponseContent,null) ?? new LicenceReqResp();
|
||||
|
||||
ses.ProvideLicense(Convert.FromBase64String(resp.license));
|
||||
|
||||
return ses.ContentKeys;
|
||||
}
|
||||
}
|
||||
|
||||
public class LicenceReqResp{
|
||||
public string status{ get; set; }
|
||||
public string license{ get; set; }
|
||||
public string platform{ get; set; }
|
||||
public string message_type{ get; set; }
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,82 @@
|
|||
using System;
|
||||
using System.Runtime.Serialization;
|
||||
using CRD.Utils.JsonConv;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils;
|
||||
|
||||
[DataContract]
|
||||
[JsonConverter(typeof(LocaleConverter))]
|
||||
public enum Locale{
|
||||
[EnumMember(Value = "")] DefaulT,
|
||||
[EnumMember(Value = "un")] Unknown,
|
||||
[EnumMember(Value = "en-US")] EnUs,
|
||||
[EnumMember(Value = "es-LA")] EsLa,
|
||||
[EnumMember(Value = "es-419")] Es419,
|
||||
[EnumMember(Value = "es-ES")] EsEs,
|
||||
[EnumMember(Value = "pt-BR")] PtBr,
|
||||
[EnumMember(Value = "fr-FR")] FrFr,
|
||||
[EnumMember(Value = "de-DE")] DeDe,
|
||||
[EnumMember(Value = "ar-ME")] ArMe,
|
||||
[EnumMember(Value = "ar-SA")] ArSa,
|
||||
[EnumMember(Value = "it-IT")] ItIt,
|
||||
[EnumMember(Value = "ru-RU")] RuRu,
|
||||
[EnumMember(Value = "tr-TR")] TrTr,
|
||||
[EnumMember(Value = "hi-IN")] HiIn,
|
||||
[EnumMember(Value = "zh-CN")] ZhCn,
|
||||
[EnumMember(Value = "ko-KR")] KoKr,
|
||||
[EnumMember(Value = "ja-JP")] JaJp,
|
||||
[EnumMember(Value = "id-ID")] IdId,
|
||||
}
|
||||
|
||||
public static class EnumExtensions{
|
||||
public static string GetEnumMemberValue(this Enum value){
|
||||
var type = value.GetType();
|
||||
var name = Enum.GetName(type, value);
|
||||
if (name != null){
|
||||
var field = type.GetField(name);
|
||||
if (field != null){
|
||||
var attr = Attribute.GetCustomAttribute(field, typeof(EnumMemberAttribute)) as EnumMemberAttribute;
|
||||
if (attr != null){
|
||||
return attr.Value ?? string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum ChannelId{
|
||||
[EnumMember(Value = "crunchyroll")] Crunchyroll,
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum ImageType{
|
||||
[EnumMember(Value = "poster_tall")] PosterTall,
|
||||
|
||||
[EnumMember(Value = "poster_wide")] PosterWide,
|
||||
|
||||
[EnumMember(Value = "promo_image")] PromoImage,
|
||||
|
||||
[EnumMember(Value = "thumbnail")] Thumbnail,
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum MaturityRating{
|
||||
[EnumMember(Value = "TV-14")] Tv14,
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum MediaType{
|
||||
[EnumMember(Value = "episode")] Episode,
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public enum DownloadMediaType{
|
||||
[EnumMember(Value = "Video")] Video,
|
||||
[EnumMember(Value = "Audio")] Audio,
|
||||
[EnumMember(Value = "Chapters")] Chapters,
|
||||
[EnumMember(Value = "Subtitle")] Subtitle,
|
||||
}
|
|
@ -0,0 +1,182 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using CRD.Downloader;
|
||||
using CRD.Utils.Structs;
|
||||
using Newtonsoft.Json;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace CRD.Utils;
|
||||
|
||||
public class CfgManager{
|
||||
private static string WorkingDirectory = Directory.GetCurrentDirectory();
|
||||
|
||||
public static readonly string PathCrToken = WorkingDirectory + "/config/cr_token.yml";
|
||||
public static readonly string PathCrDownloadOptions = WorkingDirectory + "/config/settings.yml";
|
||||
public static readonly string PathCrHistory = WorkingDirectory + "/config/history.json";
|
||||
|
||||
public static readonly string PathFFMPEG = WorkingDirectory + "/lib/ffmpeg.exe";
|
||||
public static readonly string PathMKVMERGE = WorkingDirectory + "/lib/mkvmerge.exe";
|
||||
public static readonly string PathMP4Decrypt = WorkingDirectory + "/lib/mp4decrypt.exe";
|
||||
|
||||
public static readonly string PathWIDEVINE_DIR = WorkingDirectory + "/widevine/";
|
||||
|
||||
public static readonly string PathVIDEOS_DIR = WorkingDirectory + "/video/";
|
||||
public static readonly string PathFONTS_DIR = WorkingDirectory + "/video/";
|
||||
|
||||
|
||||
public static void WriteJsonResponseToYamlFile(string jsonResponse, string filePath){
|
||||
// Convert JSON to an object
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance) // Adjust this as needed
|
||||
.Build();
|
||||
var jsonObject = deserializer.Deserialize<object>(jsonResponse);
|
||||
|
||||
// Convert the object to YAML
|
||||
var serializer = new SerializerBuilder()
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance) // Ensure consistent naming convention
|
||||
.Build();
|
||||
var yaml = serializer.Serialize(jsonObject);
|
||||
|
||||
string dirPath = Path.GetDirectoryName(filePath) ?? string.Empty;
|
||||
|
||||
if (!Directory.Exists(dirPath)){
|
||||
Directory.CreateDirectory(dirPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(filePath)){
|
||||
using (var fileStream = File.Create(filePath)){
|
||||
}
|
||||
}
|
||||
|
||||
// Write the YAML to a file
|
||||
File.WriteAllText(filePath, yaml);
|
||||
}
|
||||
|
||||
public static void WriteTokenToYamlFile(CrToken token, string filePath){
|
||||
// Convert the object to YAML
|
||||
var serializer = new SerializerBuilder()
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance) // Ensure consistent naming convention
|
||||
.Build();
|
||||
var yaml = serializer.Serialize(token);
|
||||
|
||||
string dirPath = Path.GetDirectoryName(filePath) ?? string.Empty;
|
||||
|
||||
if (!Directory.Exists(dirPath)){
|
||||
Directory.CreateDirectory(dirPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(filePath)){
|
||||
using (var fileStream = File.Create(filePath)){
|
||||
}
|
||||
}
|
||||
|
||||
// Write the YAML to a file
|
||||
File.WriteAllText(filePath, yaml);
|
||||
}
|
||||
|
||||
public static void WriteSettingsToFile(){
|
||||
var serializer = new SerializerBuilder()
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance) // Use the underscore style
|
||||
.Build();
|
||||
|
||||
string dirPath = Path.GetDirectoryName(PathCrDownloadOptions) ?? string.Empty;
|
||||
|
||||
if (!Directory.Exists(dirPath)){
|
||||
Directory.CreateDirectory(dirPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(PathCrDownloadOptions)){
|
||||
using (var fileStream = File.Create(PathCrDownloadOptions)){
|
||||
}
|
||||
}
|
||||
|
||||
var yaml = serializer.Serialize(Crunchyroll.Instance.CrunOptions);
|
||||
|
||||
// Write to file
|
||||
File.WriteAllText(PathCrDownloadOptions, yaml);
|
||||
}
|
||||
|
||||
public static void UpdateSettingsFromFile(){
|
||||
string dirPath = Path.GetDirectoryName(PathCrDownloadOptions) ?? string.Empty;
|
||||
|
||||
if (!Directory.Exists(dirPath)){
|
||||
Directory.CreateDirectory(dirPath);
|
||||
}
|
||||
|
||||
if (!File.Exists(PathCrDownloadOptions)){
|
||||
using (var fileStream = File.Create(PathCrDownloadOptions)){
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var input = File.ReadAllText(PathCrDownloadOptions);
|
||||
|
||||
if (input.Length <= 0){
|
||||
return;
|
||||
}
|
||||
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(UnderscoredNamingConvention.Instance)
|
||||
.IgnoreUnmatchedProperties() // Important to ignore properties not present in YAML
|
||||
.Build();
|
||||
|
||||
var loadedOptions = deserializer.Deserialize<CrDownloadOptions>(new StringReader(input));
|
||||
|
||||
Crunchyroll.Instance.CrunOptions.Hslang = loadedOptions.Hslang;
|
||||
Crunchyroll.Instance.CrunOptions.Novids = loadedOptions.Novids;
|
||||
Crunchyroll.Instance.CrunOptions.Noaudio = loadedOptions.Noaudio;
|
||||
Crunchyroll.Instance.CrunOptions.FileName = loadedOptions.FileName;
|
||||
Crunchyroll.Instance.CrunOptions.Numbers = loadedOptions.Numbers;
|
||||
Crunchyroll.Instance.CrunOptions.DlSubs = loadedOptions.DlSubs;
|
||||
Crunchyroll.Instance.CrunOptions.Mp4 = loadedOptions.Mp4;
|
||||
Crunchyroll.Instance.CrunOptions.FfmpegOptions = loadedOptions.FfmpegOptions;
|
||||
Crunchyroll.Instance.CrunOptions.MkvmergeOptions = loadedOptions.MkvmergeOptions;
|
||||
Crunchyroll.Instance.CrunOptions.Chapters = loadedOptions.Chapters;
|
||||
Crunchyroll.Instance.CrunOptions.SimultaneousDownloads = loadedOptions.SimultaneousDownloads;
|
||||
Crunchyroll.Instance.CrunOptions.QualityAudio = loadedOptions.QualityAudio;
|
||||
Crunchyroll.Instance.CrunOptions.QualityVideo = loadedOptions.QualityVideo;
|
||||
Crunchyroll.Instance.CrunOptions.DubLang = loadedOptions.DubLang;
|
||||
Crunchyroll.Instance.CrunOptions.Theme = loadedOptions.Theme;
|
||||
Crunchyroll.Instance.CrunOptions.AccentColor = loadedOptions.AccentColor;
|
||||
Crunchyroll.Instance.CrunOptions.History = loadedOptions.History;
|
||||
}
|
||||
|
||||
private static object fileLock = new object();
|
||||
|
||||
public static void WriteJsonToFile(string pathToFile, object obj){
|
||||
try{
|
||||
// Serialize the object to a JSON string.
|
||||
var jsonString = JsonConvert.SerializeObject(obj, Formatting.Indented);
|
||||
|
||||
// Check if the directory exists; if not, create it.
|
||||
string directoryPath = Path.GetDirectoryName(pathToFile);
|
||||
if (!Directory.Exists(directoryPath)){
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
}
|
||||
|
||||
lock (fileLock){
|
||||
// Write the JSON string to file. Creates the file if it does not exist.
|
||||
File.WriteAllText(pathToFile, jsonString);
|
||||
}
|
||||
} catch (Exception ex){
|
||||
Console.WriteLine($"An error occurred: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public static bool CheckIfFileExists(string filePath){
|
||||
string dirPath = Path.GetDirectoryName(filePath) ?? string.Empty;
|
||||
|
||||
return Directory.Exists(dirPath) && File.Exists(filePath);
|
||||
}
|
||||
|
||||
public static T DeserializeFromFile<T>(string filePath){
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.Build();
|
||||
|
||||
using (var reader = new StreamReader(filePath)){
|
||||
return deserializer.Deserialize<T>(reader);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils;
|
||||
|
||||
public class FileNameManager{
|
||||
public static List<string> ParseFileName(string input, List<Variable> variables, int numbers, List<string> @override){
|
||||
Regex varRegex = new Regex(@"\${[A-Za-z1-9]+}");
|
||||
var matches = varRegex.Matches(input).Cast<Match>().Select(m => m.Value).ToList();
|
||||
var overriddenVars = ParseOverride(variables, @override);
|
||||
if (!matches.Any())
|
||||
return new List<string>{
|
||||
input
|
||||
};
|
||||
foreach (var match in matches){
|
||||
string varName = match.Substring(2, match.Length - 3); // Removing ${ and }
|
||||
var variable = overriddenVars.FirstOrDefault(v => v.Name == varName);
|
||||
|
||||
if (variable == null){
|
||||
Console.WriteLine($"[ERROR] Found variable '{match}' in fileName but no values was internally found!");
|
||||
continue;
|
||||
}
|
||||
|
||||
string replacement = variable.ReplaceWith.ToString();
|
||||
if (variable.Type == "int32"){
|
||||
int len = replacement.Length;
|
||||
replacement = len < numbers ? new string('0', numbers - len) + replacement : replacement;
|
||||
} else if (variable.Sanitize){
|
||||
replacement = CleanupFilename(replacement);
|
||||
}
|
||||
|
||||
input = input.Replace(match, replacement);
|
||||
}
|
||||
|
||||
return input.Split(Path.DirectorySeparatorChar).Select(CleanupFilename).ToList();
|
||||
}
|
||||
|
||||
public static List<Variable> ParseOverride(List<Variable> variables, List<string>? overrides){
|
||||
if (overrides == null){
|
||||
return variables;
|
||||
}
|
||||
foreach (var item in overrides){
|
||||
int index = item.IndexOf('=');
|
||||
if (index == -1){
|
||||
Console.WriteLine($"Error: Invalid override format '{item}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
string[] parts ={ item.Substring(0, index), item.Substring(index + 1) };
|
||||
if (!(parts[1].StartsWith("'") && parts[1].EndsWith("'") && parts[1].Length >= 2)){
|
||||
Console.WriteLine($"Error: Invalid value format for '{item}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
parts[1] = parts[1][1..^1]; // Removing the surrounding single quotes
|
||||
int alreadyIndex = variables.FindIndex(a => a.Name == parts[0]);
|
||||
|
||||
if (alreadyIndex > -1){
|
||||
if (variables[alreadyIndex].Type == "number"){
|
||||
if (!float.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out float numberValue)){
|
||||
Console.WriteLine($"Error: Wrong type for '{item}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
variables[alreadyIndex].ReplaceWith = numberValue;
|
||||
} else{
|
||||
variables[alreadyIndex].ReplaceWith = parts[1];
|
||||
}
|
||||
} else{
|
||||
bool isNumber = float.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out float parsedNumber);
|
||||
variables.Add(new Variable{
|
||||
Name = parts[0],
|
||||
ReplaceWith = isNumber ? parsedNumber : (object)parts[1],
|
||||
Type = isNumber ? "number" : "string"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return variables;
|
||||
}
|
||||
|
||||
public static string CleanupFilename(string filename){
|
||||
string fixingChar = "_";
|
||||
Regex illegalRe = new Regex(@"[\/\?<>\\:\*\|"":]"); // Illegal Characters on most Operating Systems
|
||||
Regex controlRe = new Regex(@"[\x00-\x1f\x80-\x9f]"); // Unicode Control codes: C0 and C1
|
||||
Regex reservedRe = new Regex(@"^\.\.?$"); // Reserved filenames on Unix-based systems (".", "..")
|
||||
Regex windowsReservedRe = new Regex(@"^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$", RegexOptions.IgnoreCase);
|
||||
/* Reserved filenames in Windows ("CON", "PRN", "AUX", "NUL", "COM1"-"COM9", "LPT1"-"LPT9")
|
||||
case-insensitively and with or without filename extensions. */
|
||||
Regex windowsTrailingRe = new Regex(@"[\. ]+$");
|
||||
|
||||
filename = illegalRe.Replace(filename, fixingChar);
|
||||
filename = controlRe.Replace(filename, fixingChar);
|
||||
filename = reservedRe.Replace(filename, fixingChar);
|
||||
filename = windowsReservedRe.Replace(filename, fixingChar);
|
||||
filename = windowsTrailingRe.Replace(filename, fixingChar);
|
||||
|
||||
return filename;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,585 @@
|
|||
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; }
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Threading.Tasks;
|
||||
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>
|
||||
public static T? Deserialize<T>(string json,JsonSerializerSettings? serializerSettings){
|
||||
try{
|
||||
return JsonConvert.DeserializeObject<T>(json,serializerSettings);
|
||||
} catch (JsonException ex){
|
||||
Console.WriteLine($"Error deserializing JSON: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return Locale.DefaulT; // 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 async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string type, string bin, string command){
|
||||
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.Start();
|
||||
|
||||
// To log the output or errors, you might use process.StandardOutput.ReadToEndAsync()
|
||||
// string output = await process.StandardOutput.ReadToEndAsync();
|
||||
string errors = await process.StandardError.ReadToEndAsync();
|
||||
|
||||
await process.WaitForExitAsync();
|
||||
|
||||
if (!string.IsNullOrEmpty(errors))
|
||||
Console.WriteLine($"Error: {errors}");
|
||||
|
||||
// Define success condition more appropriately based on the application
|
||||
bool isSuccess = process.ExitCode == 0;
|
||||
|
||||
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Downloader;
|
||||
|
||||
namespace CRD.Utils;
|
||||
|
||||
public class HttpClientReq{
|
||||
#region Singelton
|
||||
|
||||
private static HttpClientReq? instance;
|
||||
private static readonly object padlock = new object();
|
||||
|
||||
public static HttpClientReq Instance{
|
||||
get{
|
||||
if (instance == null){
|
||||
lock (padlock){
|
||||
if (instance == null){
|
||||
instance = new HttpClientReq();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
private HttpClient client;
|
||||
private HttpClientHandler handler;
|
||||
|
||||
public HttpClientReq(){
|
||||
// Initialize the HttpClientHandler
|
||||
handler = new HttpClientHandler();
|
||||
handler.CookieContainer = new CookieContainer();
|
||||
handler.UseCookies = true;
|
||||
|
||||
// Initialize the HttpClient with the handler
|
||||
client = new HttpClient(handler);
|
||||
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0");
|
||||
|
||||
// // Set Accept headers
|
||||
// client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
|
||||
// client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xhtml+xml"));
|
||||
// client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml", 0.9));
|
||||
// client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("image/avif"));
|
||||
// client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("image/webp"));
|
||||
// client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("image/apng"));
|
||||
// client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*", 0.8));
|
||||
// client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/signed-exchange", 0.7));
|
||||
//
|
||||
// // Set Accept-Language
|
||||
// client.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en-US"));
|
||||
// client.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en", 0.9));
|
||||
//
|
||||
// // Set Cache-Control and Pragma for no caching
|
||||
// client.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue{ NoCache = true };
|
||||
// client.DefaultRequestHeaders.Pragma.Add(new NameValueHeaderValue("no-cache"));
|
||||
//
|
||||
// // Set other headers
|
||||
// client.DefaultRequestHeaders.Add("sec-ch-ua", "\"Google Chrome\";v=\"123\", \"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"123\"");
|
||||
// client.DefaultRequestHeaders.Add("sec-ch-ua-mobile", "?0");
|
||||
// client.DefaultRequestHeaders.Add("sec-ch-ua-platform", "\"Windows\"");
|
||||
// client.DefaultRequestHeaders.Add("sec-fetch-dest", "document");
|
||||
// client.DefaultRequestHeaders.Add("sec-fetch-mode", "navigate");
|
||||
// client.DefaultRequestHeaders.Add("sec-fetch-site", "none");
|
||||
// client.DefaultRequestHeaders.Add("sec-fetch-user", "?1");
|
||||
// client.DefaultRequestHeaders.Add("upgrade-insecure-requests", "1");
|
||||
}
|
||||
|
||||
public void SetETPCookie(string refresh_token){
|
||||
var cookie = new Cookie("etp_rt", refresh_token){
|
||||
Domain = "crunchyroll.com",
|
||||
Path = "/",
|
||||
};
|
||||
|
||||
handler.CookieContainer.Add(cookie);
|
||||
}
|
||||
|
||||
public async Task<(bool IsOk, string ResponseContent)> SendHttpRequest(HttpRequestMessage request){
|
||||
try{
|
||||
HttpResponseMessage response = await client.SendAsync(request);
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
string content = await response.Content.ReadAsStringAsync();
|
||||
return (IsOk: true, ResponseContent: content);
|
||||
} catch (Exception e){
|
||||
Console.WriteLine(e);
|
||||
return (IsOk: false, ResponseContent: String.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
public static HttpRequestMessage CreateRequestMessage(string uri, HttpMethod requestMethod, bool authHeader, bool disableDrmHeader, NameValueCollection? query){
|
||||
UriBuilder uriBuilder = new UriBuilder(uri);
|
||||
|
||||
if (query != null){
|
||||
uriBuilder.Query = query.ToString();
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(requestMethod, uriBuilder.ToString());
|
||||
|
||||
if (authHeader){
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Crunchyroll.Instance.Token?.access_token);
|
||||
}
|
||||
|
||||
if (disableDrmHeader){
|
||||
request.Headers.Add("X-Cr-Disable-Drm", "true");
|
||||
}
|
||||
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
public HttpClient GetHttpClient(){
|
||||
return client;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class Api{
|
||||
public static readonly string ApiBeta = "https://beta-api.crunchyroll.com";
|
||||
public static readonly string ApiN = "https://crunchyroll.com";
|
||||
|
||||
public static readonly string BetaAuth = ApiBeta + "/auth/v1/token";
|
||||
public static readonly string BetaProfile = ApiBeta + "/accounts/v1/me/profile";
|
||||
public static readonly string BetaCmsToken = ApiBeta + "/index/v2";
|
||||
public static readonly string Search = ApiBeta + "/content/v2/discover/search";
|
||||
public static readonly string Cms = ApiBeta + "/content/v2/cms";
|
||||
public static readonly string BetaBrowse = ApiBeta + "/content/v1/browse";
|
||||
public static readonly string BetaCms = ApiBeta + "/cms/v2";
|
||||
|
||||
|
||||
public static readonly string CmsN = ApiN + "/content/v2/cms";
|
||||
|
||||
|
||||
public static readonly string authBasic = "bm9haWhkZXZtXzZpeWcwYThsMHE6";
|
||||
public static readonly string authBasicMob = "bm12anNoZmtueW14eGtnN2ZiaDk6WllJVnJCV1VQYmNYRHRiRDIyVlNMYTZiNFdRb3Mzelg=";
|
||||
public static readonly string authBasicSwitch = "dC1rZGdwMmg4YzNqdWI4Zm4wZnE6eWZMRGZNZnJZdktYaDRKWFMxTEVJMmNDcXUxdjVXYW4=";
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.JsonConv;
|
||||
|
||||
public class LocaleConverter : JsonConverter{
|
||||
public override bool CanConvert(Type objectType){
|
||||
return objectType == typeof(Locale);
|
||||
}
|
||||
|
||||
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer){
|
||||
if (reader.TokenType == JsonToken.Null)
|
||||
return Locale.Unknown;
|
||||
|
||||
var value = reader.Value?.ToString();
|
||||
|
||||
foreach (Locale locale in Enum.GetValues(typeof(Locale))){
|
||||
FieldInfo fi = typeof(Locale).GetField(locale.ToString());
|
||||
EnumMemberAttribute[] attributes = (EnumMemberAttribute[])fi.GetCustomAttributes(typeof(EnumMemberAttribute), false);
|
||||
if (attributes.Length > 0 && attributes[0].Value == value)
|
||||
return locale;
|
||||
}
|
||||
|
||||
return Locale.Unknown; // Default to defaulT if no match is found
|
||||
}
|
||||
|
||||
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer){
|
||||
FieldInfo? fi = value?.GetType().GetField(value.ToString() ?? string.Empty);
|
||||
EnumMemberAttribute[] attributes = (EnumMemberAttribute[])fi.GetCustomAttributes(typeof(EnumMemberAttribute), false);
|
||||
|
||||
if (attributes.Length > 0 && !string.IsNullOrEmpty(attributes[0].Value))
|
||||
writer.WriteValue(attributes[0].Value);
|
||||
else
|
||||
writer.WriteValue(value?.ToString());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing;
|
||||
|
||||
public class FontsManager{
|
||||
#region Singelton
|
||||
|
||||
private static FontsManager? instance;
|
||||
private static readonly object padlock = new object();
|
||||
|
||||
public static FontsManager Instance{
|
||||
get{
|
||||
if (instance == null){
|
||||
lock (padlock){
|
||||
if (instance == null){
|
||||
instance = new FontsManager();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public Dictionary<string, List<string>> Fonts{ get; private set; } = new(){
|
||||
{ "Adobe Arabic", new List<string>{ "AdobeArabic-Bold.otf" } },
|
||||
{ "Andale Mono", new List<string>{ "andalemo.ttf" } },
|
||||
{ "Arial", new List<string>{ "arial.ttf", "arialbd.ttf", "arialbi.ttf", "ariali.ttf" } },
|
||||
{ "Arial Unicode MS", new List<string>{ "arialuni.ttf" } },
|
||||
{ "Arial Black", new List<string>{ "ariblk.ttf" } },
|
||||
{ "Comic Sans MS", new List<string>{ "comic.ttf", "comicbd.ttf" } },
|
||||
{ "Courier New", new List<string>{ "cour.ttf", "courbd.ttf", "courbi.ttf", "couri.ttf" } },
|
||||
{ "DejaVu LGC Sans Mono", new List<string>{ "DejaVuLGCSansMono-Bold.ttf", "DejaVuLGCSansMono-BoldOblique.ttf", "DejaVuLGCSansMono-Oblique.ttf", "DejaVuLGCSansMono.ttf" } },
|
||||
{ "DejaVu Sans", new List<string>{ "DejaVuSans-Bold.ttf", "DejaVuSans-BoldOblique.ttf", "DejaVuSans-ExtraLight.ttf", "DejaVuSans-Oblique.ttf", "DejaVuSans.ttf" } },
|
||||
{ "DejaVu Sans Condensed", new List<string>{ "DejaVuSansCondensed-Bold.ttf", "DejaVuSansCondensed-BoldOblique.ttf", "DejaVuSansCondensed-Oblique.ttf", "DejaVuSansCondensed.ttf" } },
|
||||
{ "DejaVu Sans Mono", new List<string>{ "DejaVuSansMono-Bold.ttf", "DejaVuSansMono-BoldOblique.ttf", "DejaVuSansMono-Oblique.ttf", "DejaVuSansMono.ttf" } },
|
||||
{ "Georgia", new List<string>{ "georgia.ttf", "georgiab.ttf", "georgiai.ttf", "georgiaz.ttf" } },
|
||||
{ "Impact", new List<string>{ "impact.ttf" } },
|
||||
{ "Rubik Black", new List<string>{ "Rubik-Black.ttf", "Rubik-BlackItalic.ttf" } },
|
||||
{ "Rubik", new List<string>{ "Rubik-Bold.ttf", "Rubik-BoldItalic.ttf", "Rubik-Italic.ttf", "Rubik-Light.ttf", "Rubik-LightItalic.ttf", "Rubik-Medium.ttf", "Rubik-MediumItalic.ttf", "Rubik-Regular.ttf" } },
|
||||
{ "Tahoma", new List<string>{ "tahoma.ttf" } },
|
||||
{ "Times New Roman", new List<string>{ "times.ttf", "timesbd.ttf", "timesbi.ttf", "timesi.ttf" } },
|
||||
{ "Trebuchet MS", new List<string>{ "trebuc.ttf", "trebucbd.ttf", "trebucbi.ttf", "trebucit.ttf" } },
|
||||
{ "Verdana", new List<string>{ "verdana.ttf", "verdanab.ttf", "verdanai.ttf", "verdanaz.ttf" } },
|
||||
{ "Webdings", new List<string>{ "webdings.ttf" } },
|
||||
};
|
||||
|
||||
public string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
|
||||
|
||||
public static List<string> ExtractFontsFromAss(string ass){
|
||||
var lines = ass.Replace("\r", "").Split('\n');
|
||||
var styles = new List<string>();
|
||||
|
||||
foreach (var line in lines){
|
||||
if (line.StartsWith("Style: ")){
|
||||
var parts = line.Split(',');
|
||||
if (parts.Length > 1)
|
||||
styles.Add(parts[1].Trim());
|
||||
}
|
||||
}
|
||||
|
||||
var fontMatches = Regex.Matches(ass, @"\\fn([^\\}]+)");
|
||||
foreach (Match match in fontMatches){
|
||||
if (match.Groups.Count > 1)
|
||||
styles.Add(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
return styles.Distinct().ToList(); // Using Linq to remove duplicates
|
||||
}
|
||||
|
||||
public Dictionary<string, List<string>> GetDictFromKeyList(List<string> keysList){
|
||||
|
||||
Dictionary<string, List<string>> filteredDictionary = new Dictionary<string, List<string>>();
|
||||
|
||||
foreach (string key in keysList){
|
||||
if (Fonts.TryGetValue(key, out var font)){
|
||||
filteredDictionary.Add(key, font);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredDictionary;
|
||||
|
||||
}
|
||||
|
||||
|
||||
public static string GetFontMimeType(string fontFile){
|
||||
if (Regex.IsMatch(fontFile, @"\.otf$"))
|
||||
return "application/vnd.ms-opentype";
|
||||
else if (Regex.IsMatch(fontFile, @"\.ttf$"))
|
||||
return "application/x-truetype-font";
|
||||
else
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
public List<ParsedFont> MakeFontsList(string fontsDir, List<SubtitleFonts> subs){
|
||||
Dictionary<string, List<string>> fontsNameList = new Dictionary<string, List<string>>();
|
||||
List<string> subsList = new List<string>();
|
||||
List<ParsedFont> fontsList = new List<ParsedFont>();
|
||||
bool isNstr = true;
|
||||
|
||||
foreach (var s in subs){
|
||||
foreach (var keyValuePair in s.Fonts){
|
||||
fontsNameList.Add(keyValuePair.Key,keyValuePair.Value);
|
||||
}
|
||||
subsList.Add(s.Language.Locale);
|
||||
}
|
||||
|
||||
if (subsList.Count > 0){
|
||||
Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsList), subsList.Count);
|
||||
isNstr = false;
|
||||
}
|
||||
|
||||
if (fontsNameList.Count > 0){
|
||||
Console.WriteLine((isNstr ? "\n" : "") + "Required fonts: {0} (Total: {1})", string.Join(", ", fontsNameList), fontsNameList.Count);
|
||||
}
|
||||
|
||||
foreach (var f in fontsNameList){
|
||||
if (Fonts.TryGetValue(f.Key, out var fontFiles)){
|
||||
foreach (var fontFile in fontFiles){
|
||||
string fontPath = Path.Combine(fontsDir, fontFile);
|
||||
string mime = GetFontMimeType(fontFile);
|
||||
if (File.Exists(fontPath) && new FileInfo(fontPath).Length != 0){
|
||||
fontsList.Add(new ParsedFont{ Name = fontFile, Path = fontPath, Mime = mime });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fontsList;
|
||||
}
|
||||
}
|
||||
|
||||
public class SubtitleFonts{
|
||||
public LanguageItem Language{ get; set; }
|
||||
public Dictionary<string, List<string>> Fonts{ get; set; }
|
||||
}
|
|
@ -0,0 +1,404 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils.Muxing;
|
||||
|
||||
public class Merger{
|
||||
private MergerOptions options;
|
||||
|
||||
public Merger(MergerOptions options){
|
||||
this.options = options;
|
||||
if (this.options.SkipSubMux != null && this.options.SkipSubMux == true){
|
||||
this.options.Subtitles = new List<SubtitleInput>();
|
||||
}
|
||||
|
||||
if (this.options.VideoTitle != null && this.options.VideoTitle.Length > 0){
|
||||
this.options.VideoTitle = this.options.VideoTitle.Replace("\"", "'");
|
||||
}
|
||||
}
|
||||
|
||||
public string FFmpeg(){
|
||||
List<string> args = new List<string>();
|
||||
|
||||
List<string> metaData = new List<string>();
|
||||
|
||||
var index = 0;
|
||||
var audioIndex = 0;
|
||||
var hasVideo = false;
|
||||
|
||||
if (!options.mp3){
|
||||
foreach (var vid in options.VideoAndAudio){
|
||||
if (vid.Delay != null && hasVideo){
|
||||
args.Add($"-itsoffset -{Math.Ceiling((double)vid.Delay * 1000)}ms");
|
||||
}
|
||||
|
||||
args.Add($"-i \"{vid.Path}\"");
|
||||
if (!hasVideo || options.KeepAllVideos == true){
|
||||
metaData.Add($"-map {index}:a -map {index}:v");
|
||||
metaData.Add($"-metadata:s:a:{audioIndex} language={vid.Language.Code}");
|
||||
metaData.Add($"-metadata:s:v:{index} title=\"{options.VideoTitle}\"");
|
||||
hasVideo = true;
|
||||
} else{
|
||||
metaData.Add($"-map {index}:a");
|
||||
metaData.Add($"-metadata:s:a:{audioIndex} language={vid.Language.Code}");
|
||||
}
|
||||
|
||||
audioIndex++;
|
||||
index++;
|
||||
}
|
||||
|
||||
foreach (var vid in options.OnlyVid){
|
||||
if (!hasVideo || options.KeepAllVideos == true){
|
||||
args.Add($"-i \"{vid.Path}\"");
|
||||
metaData.Add($"-map {index} -map -{index}:a");
|
||||
metaData.Add($"-metadata:s:v:{index} title=\"{options.VideoTitle}\"");
|
||||
hasVideo = true;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var aud in options.OnlyAudio){
|
||||
args.Add($"-i \"{aud.Path}\"");
|
||||
metaData.Add($"-map {index}");
|
||||
metaData.Add($"-metadata:s:a:{audioIndex} language={aud.Language.Code}");
|
||||
index++;
|
||||
audioIndex++;
|
||||
}
|
||||
|
||||
foreach (var sub in options.Subtitles.Select((value, i) => new{ value, i })){
|
||||
if (sub.value.Delay != null){
|
||||
args.Add($"-itsoffset -{Math.Ceiling((double)sub.value.Delay * 1000)}ms");
|
||||
}
|
||||
|
||||
args.Add($"-i \"{sub.value.File}\"");
|
||||
}
|
||||
|
||||
if (options.Output.EndsWith(".mkv", StringComparison.OrdinalIgnoreCase)){
|
||||
if (options.Fonts != null){
|
||||
int fontIndex = 0;
|
||||
foreach (var font in options.Fonts){
|
||||
args.Add($"-attach {font.Path} -metadata:s:t:{fontIndex} mimetype={font.Mime}");
|
||||
fontIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args.AddRange(metaData);
|
||||
args.AddRange(options.Subtitles.Select((sub, subIndex) => $"-map {subIndex + index}"));
|
||||
args.Add("-c:v copy");
|
||||
args.Add("-c:a copy");
|
||||
args.Add(options.Output.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ? "-c:s mov_text" : "-c:s ass");
|
||||
args.AddRange(options.Subtitles.Select((sub, subindex) =>
|
||||
$"-metadata:s:s:{subindex} title=\"{sub.Language.Language ?? sub.Language.Name}{(sub.ClosedCaption == true ? $" {options.CcTag}" : "")}{(sub.Signs == true ? " Signs" : "")}\" -metadata:s:s:{subindex} language={sub.Language.Code}"));
|
||||
if (options.Options.ffmpeg?.Count > 0){
|
||||
args.AddRange(options.Options.ffmpeg);
|
||||
}
|
||||
args.Add($"\"{options.Output}\"");
|
||||
|
||||
return string.Join(" ", args);
|
||||
}
|
||||
|
||||
|
||||
args.Add($"-i \"{options.OnlyAudio[0].Path}\"");
|
||||
args.Add("-acodec libmp3lame");
|
||||
args.Add("-ab 192k");
|
||||
args.Add($"\"{options.Output}\"");
|
||||
return string.Join(" ", args);
|
||||
}
|
||||
|
||||
public string MkvMerge(){
|
||||
List<string> args = new List<string>();
|
||||
|
||||
bool hasVideo = false;
|
||||
|
||||
args.Add($"-o \"{options.Output}\"");
|
||||
if (options.Options.mkvmerge != null){
|
||||
args.AddRange(options.Options.mkvmerge);
|
||||
}
|
||||
|
||||
|
||||
foreach (var vid in options.OnlyVid){
|
||||
if (!hasVideo || options.KeepAllVideos == true){
|
||||
args.Add("--video-tracks 0");
|
||||
args.Add("--no-audio");
|
||||
|
||||
string trackName = $"{(options.VideoTitle ?? vid.Language.Name)}{(options.Simul == true ? " [Simulcast]" : " [Uncut]")}";
|
||||
args.Add($"--track-name 0:\"{trackName}\"");
|
||||
args.Add($"--language 0:{vid.Language.Code}");
|
||||
|
||||
hasVideo = true;
|
||||
args.Add($"\"{vid.Path}\"");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var vid in options.VideoAndAudio){
|
||||
string audioTrackNum = options.InverseTrackOrder == true ? "0" : "1";
|
||||
string videoTrackNum = options.InverseTrackOrder == true ? "1" : "0";
|
||||
|
||||
if (vid.Delay.HasValue){
|
||||
double delay = vid.Delay ?? 0;
|
||||
args.Add($"--sync {audioTrackNum}:-{Math.Ceiling(delay * 1000)}");
|
||||
}
|
||||
|
||||
if (!hasVideo || options.KeepAllVideos == true){
|
||||
args.Add($"--video-tracks {videoTrackNum}");
|
||||
args.Add($"--audio-tracks {audioTrackNum}");
|
||||
|
||||
string trackName = $"{(options.VideoTitle ?? vid.Language.Name)}{(options.Simul == true ? " [Simulcast]" : " [Uncut]")}";
|
||||
args.Add($"--track-name 0:\"{trackName}\""); // Assuming trackName applies to video if present
|
||||
args.Add($"--language {audioTrackNum}:{vid.Language.Code}");
|
||||
|
||||
if (options.Defaults.Audio.Code == vid.Language.Code){
|
||||
args.Add($"--default-track {audioTrackNum}");
|
||||
} else{
|
||||
args.Add($"--default-track {audioTrackNum}:0");
|
||||
}
|
||||
|
||||
hasVideo = true;
|
||||
} else{
|
||||
args.Add("--no-video");
|
||||
args.Add($"--audio-tracks {audioTrackNum}");
|
||||
|
||||
if (options.Defaults.Audio.Code == vid.Language.Code){
|
||||
args.Add($"--default-track {audioTrackNum}");
|
||||
} else{
|
||||
args.Add($"--default-track {audioTrackNum}:0");
|
||||
}
|
||||
|
||||
args.Add($"--track-name {audioTrackNum}:\"{vid.Language.Name}\"");
|
||||
args.Add($"--language {audioTrackNum}:{vid.Language.Code}");
|
||||
}
|
||||
|
||||
args.Add($"\"{vid.Path}\"");
|
||||
}
|
||||
|
||||
foreach (var aud in options.OnlyAudio){
|
||||
string trackName = aud.Language.Name;
|
||||
args.Add($"--track-name 0:\"{trackName}\"");
|
||||
args.Add($"--language 0:{aud.Language.Code}");
|
||||
args.Add("--no-video");
|
||||
args.Add("--audio-tracks 0");
|
||||
|
||||
if (options.Defaults.Audio.Code == aud.Language.Code){
|
||||
args.Add("--default-track 0");
|
||||
} else{
|
||||
args.Add("--default-track 0:0");
|
||||
}
|
||||
|
||||
args.Add($"\"{aud.Path}\"");
|
||||
}
|
||||
|
||||
if (options.Subtitles.Count > 0){
|
||||
foreach (var subObj in options.Subtitles){
|
||||
if (subObj.Delay.HasValue){
|
||||
double delay = subObj.Delay ?? 0;
|
||||
args.Add($"--sync 0:-{Math.Ceiling(delay * 1000)}");
|
||||
}
|
||||
|
||||
string trackNameExtra = subObj.ClosedCaption == true ? $" {options.CcTag}" : "";
|
||||
trackNameExtra += subObj.Signs == true ? " Signs" : "";
|
||||
|
||||
string trackName = $"0:\"{(subObj.Language.Language ?? subObj.Language.Name) + trackNameExtra}\"";
|
||||
args.Add($"--track-name {trackName}");
|
||||
args.Add($"--language 0:\"{subObj.Language.Code}\"");
|
||||
|
||||
if (options.Defaults.Sub.Code == subObj.Language.Code && subObj.ClosedCaption == false){
|
||||
args.Add("--default-track 0");
|
||||
} else{
|
||||
args.Add("--default-track 0:0");
|
||||
}
|
||||
|
||||
args.Add($"\"{subObj.File}\"");
|
||||
}
|
||||
} else{
|
||||
args.Add("--no-subtitles");
|
||||
}
|
||||
|
||||
if (options.Fonts != null && options.Fonts.Count > 0){
|
||||
foreach (var font in options.Fonts){
|
||||
args.Add($"--attachment-name \"{font.Name}\"");
|
||||
args.Add($"--attachment-mime-type \"{font.Mime}\"");
|
||||
args.Add($"--attach-file \"{font.Path}\"");
|
||||
}
|
||||
} else{
|
||||
args.Add("--no-attachments");
|
||||
}
|
||||
|
||||
if (options.Chapters != null && options.Chapters.Count > 0){
|
||||
args.Add($"--chapters \"{options.Chapters[0].Path}\"");
|
||||
}
|
||||
|
||||
|
||||
return string.Join(" ", args);
|
||||
}
|
||||
|
||||
// public async Task CreateDelays(){
|
||||
// // Don't bother scanning if there is only 1 vna stream
|
||||
// if (options.VideoAndAudio.Count > 1){
|
||||
// var bin = await YamlCfg.LoadBinCfg();
|
||||
// var vnas = this.options.VideoAndAudio;
|
||||
//
|
||||
// // Get and set durations on each videoAndAudio Stream
|
||||
// foreach (var vna in vnas){
|
||||
// var streamInfo = await FFProbe(vna.Path, bin.FFProbe);
|
||||
// var videoInfo = streamInfo.Streams.Where(stream => stream.CodecType == "video").FirstOrDefault();
|
||||
// vna.Duration = int.Parse(videoInfo.Duration);
|
||||
// }
|
||||
//
|
||||
// // Sort videoAndAudio streams by duration (shortest first)
|
||||
// vnas.Sort((a, b) => {
|
||||
// if (a.Duration == 0 || b.Duration == 0) return -1;
|
||||
// return a.Duration.CompareTo(b.Duration);
|
||||
// });
|
||||
//
|
||||
// // Set Delays
|
||||
// var shortestDuration = vnas[0].Duration;
|
||||
// foreach (var (vna, index) in vnas.Select((vna, index) => (vna, index))){
|
||||
// // Don't calculate the shortestDuration track
|
||||
// if (index == 0){
|
||||
// if (!vna.IsPrimary)
|
||||
// Console.WriteLine("Shortest video isn't primary, this might lead to problems with subtitles. Please report on github or discord if you experience issues.");
|
||||
// continue;
|
||||
// }
|
||||
//
|
||||
// if (vna.Duration > 0 && shortestDuration > 0){
|
||||
// // Calculate the tracks delay
|
||||
// vna.Delay = Math.Ceiling((vna.Duration - shortestDuration) * 1000) / 1000;
|
||||
//
|
||||
// var subtitles = this.options.Subtitles.Where(sub => sub.Language.Code == vna.Lang.Code).ToList();
|
||||
// foreach (var (sub, subIndex) in subtitles.Select((sub, subIndex) => (sub, subIndex))){
|
||||
// if (vna.IsPrimary)
|
||||
// subtitles[subIndex].Delay = vna.Delay;
|
||||
// else if (sub.ClosedCaption)
|
||||
// subtitles[subIndex].Delay = vna.Delay;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
public async Task Merge(string type, string bin){
|
||||
string command = type switch{
|
||||
"ffmpeg" => FFmpeg(),
|
||||
"mkvmerge" => MkvMerge(),
|
||||
_ => ""
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(command)){
|
||||
Console.WriteLine("Unable to merge files.");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[{type}] Started merging");
|
||||
var result = await Helpers.ExecuteCommandAsync(type, bin, command);
|
||||
|
||||
if (!result.IsOk && type == "mkvmerge" && result.ErrorCode == 1){
|
||||
Console.WriteLine($"[{type}] Mkvmerge finished with at least one warning");
|
||||
} else if (!result.IsOk){
|
||||
Console.WriteLine($"[{type}] Merging failed with exit code {result.ErrorCode}");
|
||||
} else{
|
||||
Console.WriteLine($"[{type} Done]");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void CleanUp(){
|
||||
// Combine all media file lists and iterate through them
|
||||
var allMediaFiles = options.OnlyAudio.Concat(options.OnlyVid)
|
||||
.Concat(options.VideoAndAudio).ToList();
|
||||
allMediaFiles.ForEach(file => DeleteFile(file.Path));
|
||||
allMediaFiles.ForEach(file => DeleteFile(file.Path + ".resume"));
|
||||
|
||||
// Delete chapter files if any
|
||||
options.Chapters?.ForEach(chapter => DeleteFile(chapter.Path));
|
||||
|
||||
// Delete subtitle files
|
||||
options.Subtitles.ForEach(subtitle => DeleteFile(subtitle.File));
|
||||
}
|
||||
|
||||
private void DeleteFile(string filePath){
|
||||
try{
|
||||
if (File.Exists(filePath)){
|
||||
File.Delete(filePath);
|
||||
}
|
||||
} catch (Exception ex){
|
||||
Console.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}");
|
||||
// Handle exceptions if you need to log them or throw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class MergerInput{
|
||||
public string Path{ get; set; }
|
||||
public LanguageItem Language{ get; set; }
|
||||
public int? Duration{ get; set; }
|
||||
public int? Delay{ get; set; }
|
||||
public bool? IsPrimary{ get; set; }
|
||||
}
|
||||
|
||||
public class SubtitleInput{
|
||||
public LanguageItem Language{ get; set; }
|
||||
public string File{ get; set; }
|
||||
public bool? ClosedCaption{ get; set; }
|
||||
public bool? Signs{ get; set; }
|
||||
public int? Delay{ get; set; }
|
||||
}
|
||||
|
||||
public class ParsedFont{
|
||||
public string Name{ get; set; }
|
||||
public string Path{ get; set; }
|
||||
public string Mime{ get; set; }
|
||||
}
|
||||
|
||||
public class CrunchyMuxOptions{
|
||||
public string Output{ get; set; }
|
||||
public bool? SkipSubMux{ get; set; }
|
||||
public bool? KeepAllVideos{ get; set; }
|
||||
public bool? Novids{ get; set; }
|
||||
public bool Mp4{ get; set; }
|
||||
public string ForceMuxer{ get; set; }
|
||||
public bool? NoCleanup{ get; set; }
|
||||
public string VideoTitle{ get; set; }
|
||||
public List<string> FfmpegOptions{ get; set; } = new List<string>();
|
||||
public List<string> MkvmergeOptions{ get; set; } = new List<string>();
|
||||
public LanguageItem DefaultSub{ get; set; }
|
||||
public LanguageItem DefaultAudio{ get; set; }
|
||||
public string CcTag{ get; set; }
|
||||
public bool SyncTiming{ get; set; }
|
||||
}
|
||||
|
||||
public class MergerOptions{
|
||||
public List<MergerInput> VideoAndAudio{ get; set; } = new List<MergerInput>();
|
||||
public List<MergerInput> OnlyVid{ get; set; } = new List<MergerInput>();
|
||||
public List<MergerInput> OnlyAudio{ get; set; } = new List<MergerInput>();
|
||||
public List<SubtitleInput> Subtitles{ get; set; } = new List<SubtitleInput>();
|
||||
public List<MergerInput> Chapters{ get; set; } = new List<MergerInput>();
|
||||
public string CcTag{ get; set; }
|
||||
public string Output{ get; set; }
|
||||
public string VideoTitle{ get; set; }
|
||||
public bool? Simul{ get; set; }
|
||||
public bool? InverseTrackOrder{ get; set; }
|
||||
public bool? KeepAllVideos{ get; set; }
|
||||
public List<ParsedFont> Fonts{ get; set; } = new List<ParsedFont>();
|
||||
public bool? SkipSubMux{ get; set; }
|
||||
public MuxOptions Options{ get; set; }
|
||||
public Defaults Defaults{ get; set; }
|
||||
|
||||
public bool mp3{ get; set; }
|
||||
}
|
||||
|
||||
public class MuxOptions{
|
||||
public List<string>? ffmpeg{ get; set; }
|
||||
public List<string>? mkvmerge{ get; set; }
|
||||
}
|
||||
|
||||
public class Defaults{
|
||||
public LanguageItem Audio{ get; set; }
|
||||
public LanguageItem Sub{ get; set; }
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Xml;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.Parser;
|
||||
|
||||
public class DashParser{
|
||||
|
||||
public static dynamic Parse(string manifest, dynamic? options = null){
|
||||
var parsedManifestInfo = InheritAttributes.InheritAttributesFun(StringToMpdXml(manifest));
|
||||
List<dynamic> playlists = ToPlaylistsClass.ToPlaylists(parsedManifestInfo.representationInfo);
|
||||
|
||||
dynamic parsedElement = new{
|
||||
dashPlaylist = playlists,
|
||||
locations= parsedManifestInfo.locations,
|
||||
contentSteering= parsedManifestInfo.contentSteeringInfo,
|
||||
sidxMapping= options != null ? ObjectUtilities.GetMemberValue(options,"sidxMapping") : null,
|
||||
previousManifest= options != null ? ObjectUtilities.GetMemberValue(options,"previousManifest") : null,
|
||||
eventStream= ObjectUtilities.GetMemberValue(parsedManifestInfo,"eventStream")
|
||||
};
|
||||
|
||||
return ToM3u8Class.ToM3u8(parsedElement);
|
||||
// string jsonString = JsonConvert.SerializeObject(M3u8);
|
||||
|
||||
Console.WriteLine("Hallo");
|
||||
}
|
||||
|
||||
private static XmlElement StringToMpdXml(string manifestString){
|
||||
if (string.IsNullOrEmpty(manifestString))
|
||||
{
|
||||
throw new Exception(Errors.DASH_EMPTY_MANIFEST);
|
||||
}
|
||||
|
||||
XmlDocument xml = new XmlDocument();
|
||||
XmlElement mpd = null;
|
||||
|
||||
try
|
||||
{
|
||||
xml.LoadXml(manifestString);
|
||||
mpd = xml.DocumentElement.Name == "MPD" ? xml.DocumentElement : null;
|
||||
}
|
||||
catch (XmlException)
|
||||
{
|
||||
// ie 11 throws on invalid xml
|
||||
}
|
||||
|
||||
if (mpd == null || (mpd != null && mpd.GetElementsByTagName("parsererror").Count > 0))
|
||||
{
|
||||
throw new Exception(Errors.DASH_INVALID_XML);
|
||||
}
|
||||
|
||||
return mpd;
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,495 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
using CRD.Utils.Parser.Segments;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
|
||||
namespace CRD.Utils.Parser;
|
||||
|
||||
public class ToM3u8Class{
|
||||
public static dynamic ToM3u8(dynamic parsedPlaylists){
|
||||
List<dynamic> dashPlaylist = ObjectUtilities.GetMemberValue(parsedPlaylists, "dashPlaylist");
|
||||
dynamic locations = ObjectUtilities.GetMemberValue(parsedPlaylists, "locations");
|
||||
dynamic contentSteering = ObjectUtilities.GetMemberValue(parsedPlaylists, "contentSteering");
|
||||
dynamic sidxMapping = ObjectUtilities.GetMemberValue(parsedPlaylists, "sidxMapping");
|
||||
dynamic previousManifest = ObjectUtilities.GetMemberValue(parsedPlaylists, "previousManifest");
|
||||
dynamic eventStream = ObjectUtilities.GetMemberValue(parsedPlaylists, "eventStream");
|
||||
|
||||
if (dashPlaylist == null || dashPlaylist.Count == 0){
|
||||
return new{ };
|
||||
}
|
||||
|
||||
dynamic attributes = dashPlaylist[0].attributes;
|
||||
|
||||
dynamic duration = ObjectUtilities.GetMemberValue(attributes, "sourceDuration");
|
||||
dynamic type = ObjectUtilities.GetMemberValue(attributes, "type");
|
||||
dynamic suggestedPresentationDelay = ObjectUtilities.GetMemberValue(attributes, "suggestedPresentationDelay");
|
||||
dynamic minimumUpdatePeriod = ObjectUtilities.GetMemberValue(attributes, "minimumUpdatePeriod");
|
||||
|
||||
|
||||
List<dynamic> videoPlaylists = MergeDiscontiguousPlaylists(dashPlaylist.FindAll(VideoOnly)).Select(FormatVideoPlaylist).ToList();
|
||||
List<dynamic> audioPlaylists = MergeDiscontiguousPlaylists(dashPlaylist.FindAll(AudioOnly));
|
||||
List<dynamic> vttPlaylists = MergeDiscontiguousPlaylists(dashPlaylist.FindAll(VttOnly));
|
||||
List<dynamic> captions = dashPlaylist
|
||||
.Select(playlist => ObjectUtilities.GetMemberValue(playlist.attributes, "captionServices"))
|
||||
.Where(captionService => captionService != null) // Filtering out null values
|
||||
.ToList();
|
||||
|
||||
dynamic manifest = new ExpandoObject();
|
||||
manifest.allowCache = true;
|
||||
manifest.discontinuityStarts = new List<dynamic>();
|
||||
manifest.segments = new List<dynamic>();
|
||||
manifest.endList = true;
|
||||
manifest.mediaGroups = new ExpandoObject();
|
||||
manifest.mediaGroups.AUDIO = new ExpandoObject();
|
||||
manifest.mediaGroups.VIDEO = new ExpandoObject();
|
||||
manifest.mediaGroups.SUBTITLES = new ExpandoObject();
|
||||
manifest.uri = "";
|
||||
manifest.duration = duration;
|
||||
manifest.playlists = AddSidxSegmentsToPlaylists(videoPlaylists, sidxMapping);
|
||||
|
||||
var mediaGroupsDict = (IDictionary<string, object>)manifest.mediaGroups;
|
||||
mediaGroupsDict["CLOSED-CAPTIONS"] = new ExpandoObject();
|
||||
|
||||
if (minimumUpdatePeriod != null && minimumUpdatePeriod >= 0){
|
||||
manifest.minimumUpdatePeriod = minimumUpdatePeriod * 1000;
|
||||
}
|
||||
|
||||
if (locations != null){
|
||||
manifest.locations = locations;
|
||||
}
|
||||
|
||||
if (contentSteering != null){
|
||||
manifest.contentSteering = contentSteering;
|
||||
}
|
||||
|
||||
if (type != null && type == "dynamic"){
|
||||
manifest.suggestedPresentationDelay = suggestedPresentationDelay;
|
||||
}
|
||||
|
||||
if (eventStream != null && eventStream.Count > 0){
|
||||
manifest.eventStream = eventStream;
|
||||
}
|
||||
|
||||
|
||||
var isAudioOnly = ((List<dynamic>)manifest.playlists).Count == 0;
|
||||
var organizedAudioGroup = audioPlaylists.Count > 0 ? OrganizeAudioPlaylists(audioPlaylists, sidxMapping, isAudioOnly) : null;
|
||||
var organizedVttGroup = vttPlaylists.Count > 0 ? OrganizeVttPlaylists(vttPlaylists, sidxMapping) : null;
|
||||
|
||||
List<dynamic> formattedPlaylists = new List<dynamic>(videoPlaylists);
|
||||
|
||||
formattedPlaylists.AddRange(FlattenMediaGroupPlaylists(organizedAudioGroup));
|
||||
formattedPlaylists.AddRange(FlattenMediaGroupPlaylists(organizedVttGroup));
|
||||
|
||||
|
||||
dynamic playlistTimelineStarts = formattedPlaylists.Select(playlist => playlist.timelineStarts).ToList();
|
||||
|
||||
List<List<dynamic>> convertedToList = new List<List<dynamic>>();
|
||||
foreach (var item in playlistTimelineStarts){
|
||||
if (item is List<dynamic>){
|
||||
convertedToList.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
manifest.timelineStarts = PlaylistMerge.GetUniqueTimelineStarts(convertedToList);
|
||||
|
||||
AddMediaSequenceValues(formattedPlaylists, manifest.timelineStarts);
|
||||
|
||||
if (organizedAudioGroup != null){
|
||||
manifest.mediaGroups.AUDIO.audio = organizedAudioGroup;
|
||||
}
|
||||
|
||||
if (organizedVttGroup != null){
|
||||
manifest.mediaGroups.SUBTITLES.subs = organizedVttGroup;
|
||||
}
|
||||
|
||||
if (captions.Count > 0){
|
||||
dynamic closedCaptions = mediaGroupsDict["CLOSED-CAPTIONS"];
|
||||
closedCaptions.cc = OrganizeCaptionServices(captions);
|
||||
}
|
||||
|
||||
if (previousManifest != null){
|
||||
return PlaylistMerge.PositionManifestOnTimeline(previousManifest, manifest);
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
public static bool VideoOnly(dynamic item){
|
||||
var attributes = item.attributes;
|
||||
return ObjectUtilities.GetMemberValue(attributes, "mimeType") == "video/mp4" || ObjectUtilities.GetMemberValue(attributes, "mimeType") == "video/webm" ||
|
||||
ObjectUtilities.GetMemberValue(attributes, "contentType") == "video";
|
||||
}
|
||||
|
||||
public static bool AudioOnly(dynamic item){
|
||||
var attributes = item.attributes;
|
||||
return ObjectUtilities.GetMemberValue(attributes, "mimeType") == "audio/mp4" || ObjectUtilities.GetMemberValue(attributes, "mimeType") == "audio/webm" ||
|
||||
ObjectUtilities.GetMemberValue(attributes, "contentType") == "audio";
|
||||
}
|
||||
|
||||
public static bool VttOnly(dynamic item){
|
||||
var attributes = item.attributes;
|
||||
return ObjectUtilities.GetMemberValue(attributes, "mimeType") == "text/vtt" || ObjectUtilities.GetMemberValue(attributes, "contentType") == "text";
|
||||
}
|
||||
|
||||
public static dynamic FormatVideoPlaylist(dynamic item){
|
||||
dynamic playlist = new ExpandoObject();
|
||||
playlist.attributes = new ExpandoObject();
|
||||
playlist.attributes.NAME = item.attributes.id;
|
||||
playlist.attributes.AUDIO = "audio";
|
||||
playlist.attributes.SUBTITLES = "subs";
|
||||
playlist.attributes.RESOLUTION = new ExpandoObject();
|
||||
playlist.attributes.RESOLUTION.width = item.attributes.width;
|
||||
playlist.attributes.RESOLUTION.height = item.attributes.height;
|
||||
playlist.attributes.CODECS = item.attributes.codecs;
|
||||
playlist.attributes.BANDWIDTH = item.attributes.bandwidth;
|
||||
playlist.uri = "";
|
||||
playlist.endList = item.attributes.type == "static";
|
||||
playlist.timeline = item.attributes.periodStart;
|
||||
playlist.resolvedUri = item.attributes.baseUrl ?? "";
|
||||
playlist.targetDuration = item.attributes.duration;
|
||||
playlist.discontinuityStarts = item.discontinuityStarts;
|
||||
playlist.timelineStarts = item.attributes.timelineStarts;
|
||||
playlist.segments = item.segments;
|
||||
|
||||
var attributesDict = (IDictionary<string, object>)playlist.attributes;
|
||||
attributesDict["PROGRAM-ID"] = 1;
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item.attributes, "frameRate") != null){
|
||||
attributesDict["FRAME-RATE"] = item.attributes.frameRate;
|
||||
}
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item.attributes, "contentProtection") != null){
|
||||
playlist.contentProtection = item.attributes.contentProtection;
|
||||
}
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item.attributes, "serviceLocation") != null){
|
||||
playlist.attributes.serviceLocation = item.attributes.serviceLocation;
|
||||
}
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item, "sidx") != null){
|
||||
playlist.sidx = item.sidx;
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
public static dynamic FormatAudioPlaylist(dynamic item, bool isAudioOnly){
|
||||
dynamic playlist = new ExpandoObject();
|
||||
playlist.attributes = new ExpandoObject();
|
||||
playlist.attributes.NAME = item.attributes.id;
|
||||
playlist.attributes.BANDWIDTH = item.attributes.bandwidth;
|
||||
playlist.attributes.CODECS = item.attributes.codecs;
|
||||
playlist.uri = string.Empty;
|
||||
playlist.endList = item.attributes.type == "static";
|
||||
playlist.timeline = item.attributes.periodStart;
|
||||
playlist.resolvedUri = item.attributes.baseUrl ?? string.Empty;
|
||||
playlist.targetDuration = item.attributes.duration;
|
||||
playlist.discontinuitySequence = ObjectUtilities.GetMemberValue(item, "discontinuitySequence");
|
||||
playlist.discontinuityStarts = item.discontinuityStarts;
|
||||
playlist.timelineStarts = item.attributes.timelineStarts;
|
||||
playlist.mediaSequence = ObjectUtilities.GetMemberValue(item, "mediaSequence");
|
||||
playlist.segments = item.segments;
|
||||
|
||||
var attributesDict = (IDictionary<string, object>)playlist.attributes;
|
||||
attributesDict["PROGRAM-ID"] = 1;
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item.attributes, "contentProtection") != null){
|
||||
playlist.contentProtection = item.attributes.contentProtection;
|
||||
}
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item.attributes, "serviceLocation") != null){
|
||||
playlist.attributes.serviceLocation = item.attributes.serviceLocation;
|
||||
}
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item, "sidx") != null){
|
||||
playlist.sidx = item.sidx;
|
||||
}
|
||||
|
||||
if (isAudioOnly){
|
||||
playlist.attributes.AUDIO = "audio";
|
||||
playlist.attributes.SUBTITLES = "subs";
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
public static dynamic FormatVttPlaylist(dynamic item){
|
||||
if (ObjectUtilities.GetMemberValue(item,"segments") == null){
|
||||
// VTT tracks may use a single file in BaseURL
|
||||
var segment = new ExpandoObject() as IDictionary<string, object>;
|
||||
segment["uri"] = item.attributes.baseUrl;
|
||||
segment["timeline"] = item.attributes.periodStart;
|
||||
segment["resolvedUri"] = item.attributes.baseUrl ?? string.Empty;
|
||||
segment["duration"] = item.attributes.sourceDuration;
|
||||
segment["number"] = 0;
|
||||
|
||||
item.segments = new List<dynamic>{ segment };
|
||||
|
||||
// TargetDuration should be the same duration as the only segment
|
||||
item.attributes.duration = item.attributes.sourceDuration;
|
||||
}
|
||||
|
||||
var m3u8Attributes = new ExpandoObject() as IDictionary<string, object>;
|
||||
m3u8Attributes["NAME"] = item.attributes.id;
|
||||
m3u8Attributes["BANDWIDTH"] = item.attributes.bandwidth;
|
||||
m3u8Attributes["PROGRAM-ID"] = 1;
|
||||
|
||||
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item.attributes,"codecs") != null){
|
||||
m3u8Attributes["CODECS"] = item.attributes.codecs;
|
||||
}
|
||||
|
||||
dynamic vttPlaylist = new ExpandoObject();
|
||||
vttPlaylist.attributes = m3u8Attributes;
|
||||
vttPlaylist.uri = string.Empty;
|
||||
vttPlaylist.endList = item.attributes.type == "static";
|
||||
vttPlaylist.timeline = item.attributes.periodStart;
|
||||
vttPlaylist.resolvedUri = item.attributes.baseUrl ?? string.Empty;
|
||||
vttPlaylist.targetDuration = item.attributes.duration;
|
||||
vttPlaylist.timelineStarts = item.attributes.timelineStarts;
|
||||
vttPlaylist.discontinuityStarts = item.discontinuityStarts;
|
||||
vttPlaylist.discontinuitySequence = ObjectUtilities.GetMemberValue(item, "discontinuitySequence");
|
||||
vttPlaylist.mediaSequence = ObjectUtilities.GetMemberValue(item,"mediaSequence");
|
||||
vttPlaylist.segments = item.segments;
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(item.attributes, "serviceLocation") != null){
|
||||
vttPlaylist.attributes.serviceLocation = item.attributes.serviceLocation;
|
||||
}
|
||||
|
||||
return vttPlaylist;
|
||||
}
|
||||
|
||||
public static dynamic OrganizeCaptionServices(List<dynamic> captionServices){
|
||||
var svcObj = new ExpandoObject() as IDictionary<string, object>;
|
||||
|
||||
foreach (var svc in captionServices){
|
||||
if (svc == null) continue;
|
||||
|
||||
foreach (var service in svc){
|
||||
string channel = service.channel;
|
||||
string language = service.language;
|
||||
|
||||
var serviceDetails = new ExpandoObject() as IDictionary<string, object>;
|
||||
serviceDetails["autoselect"] = false;
|
||||
serviceDetails["default"] = false;
|
||||
serviceDetails["instreamId"] = channel;
|
||||
serviceDetails["language"] = language;
|
||||
|
||||
// Optionally add properties if they exist
|
||||
if (((IDictionary<string, object>)service).ContainsKey("aspectRatio")){
|
||||
serviceDetails["aspectRatio"] = service.aspectRatio;
|
||||
}
|
||||
|
||||
if (((IDictionary<string, object>)service).ContainsKey("easyReader")){
|
||||
serviceDetails["easyReader"] = service.easyReader;
|
||||
}
|
||||
|
||||
if (((IDictionary<string, object>)service).ContainsKey("3D")){
|
||||
serviceDetails["3D"] = service["3D"];
|
||||
}
|
||||
|
||||
svcObj[language] = serviceDetails;
|
||||
}
|
||||
}
|
||||
|
||||
return svcObj;
|
||||
}
|
||||
|
||||
public static List<dynamic> FlattenMediaGroupPlaylists(dynamic mediaGroupObject){
|
||||
if (mediaGroupObject == null) return new List<dynamic>();
|
||||
|
||||
var result = new List<dynamic>();
|
||||
foreach (var key in ((IDictionary<string, dynamic>)mediaGroupObject).Keys){
|
||||
var labelContents = mediaGroupObject[key];
|
||||
if (labelContents.playlists != null && labelContents.playlists is List<dynamic>){
|
||||
result.AddRange(labelContents.playlists);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
public static List<dynamic> MergeDiscontiguousPlaylists(List<dynamic> playlists){
|
||||
// Break out playlists into groups based on their baseUrl
|
||||
var playlistsByBaseUrl = playlists.GroupBy(
|
||||
p => p.attributes.baseUrl,
|
||||
p => p,
|
||||
(key, g) => new{ BaseUrl = key, Playlists = g.ToList() })
|
||||
.ToDictionary(g => g.BaseUrl, g => g.Playlists);
|
||||
|
||||
var allPlaylists = new List<dynamic>();
|
||||
|
||||
foreach (var playlistGroup in playlistsByBaseUrl.Values){
|
||||
var mergedPlaylists = playlistGroup
|
||||
.GroupBy(
|
||||
p => p.attributes.id + (ObjectUtilities.GetMemberValue(p.attributes, "lang") ?? ""),
|
||||
p => p,
|
||||
(key, g) => new{ Name = key, Playlists = g.ToList() })
|
||||
.Select(g => {
|
||||
dynamic mergedPlaylist = new ExpandoObject();
|
||||
mergedPlaylist.attributes = new ExpandoObject();
|
||||
mergedPlaylist.attributes.timelineStarts = new List<dynamic>();
|
||||
|
||||
foreach (var playlist in g.Playlists){
|
||||
if (ObjectUtilities.GetMemberValue(mergedPlaylist, "segments") == null){
|
||||
mergedPlaylist = playlist;
|
||||
mergedPlaylist.attributes.timelineStarts = new List<dynamic>();
|
||||
} else{
|
||||
if (playlist.segments != null && playlist.segments.Count > 0){
|
||||
playlist.segments[0].discontinuity = true;
|
||||
foreach (var segment in playlist.segments){
|
||||
mergedPlaylist.segments.Add(segment);
|
||||
}
|
||||
}
|
||||
|
||||
if (playlist.attributes.contentProtection != null){
|
||||
mergedPlaylist.attributes.contentProtection = playlist.attributes.contentProtection;
|
||||
}
|
||||
}
|
||||
|
||||
mergedPlaylist.attributes.timelineStarts.Add(new{
|
||||
start = playlist.attributes.periodStart,
|
||||
timeline = playlist.attributes.periodStart
|
||||
});
|
||||
}
|
||||
|
||||
return mergedPlaylist;
|
||||
})
|
||||
.ToList();
|
||||
|
||||
allPlaylists.AddRange(mergedPlaylists);
|
||||
}
|
||||
|
||||
return allPlaylists.Select(playlist => {
|
||||
playlist.discontinuityStarts = FindIndexes((List<dynamic>) ObjectUtilities.GetMemberValue(playlists,"segments") ?? new List<dynamic>(), "discontinuity");
|
||||
return playlist;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public static IDictionary<string, dynamic> OrganizeAudioPlaylists(List<dynamic> playlists, IDictionary<string, dynamic>? sidxMapping = null, bool isAudioOnly = false){
|
||||
sidxMapping ??= new Dictionary<string, dynamic>(); // Ensure sidxMapping is not null
|
||||
dynamic mainPlaylist = null;
|
||||
|
||||
var formattedPlaylists = playlists.Aggregate(new Dictionary<string, dynamic>(), (acc, playlist) => {
|
||||
var role = ObjectUtilities.GetMemberValue(playlist.attributes, "role") != null && ObjectUtilities.GetMemberValue(playlist.attributes.role, "value") != null ? playlist.attributes.role.value : string.Empty;
|
||||
var language = ObjectUtilities.GetMemberValue(playlist.attributes, "lang") ?? string.Empty;
|
||||
|
||||
var label = ObjectUtilities.GetMemberValue(playlist.attributes, "label") ?? "main";
|
||||
if (!string.IsNullOrEmpty(language) && string.IsNullOrEmpty(playlist.attributes.label)){
|
||||
var roleLabel = !string.IsNullOrEmpty(role) ? $" ({role})" : string.Empty;
|
||||
label = $"{language}{roleLabel}";
|
||||
}
|
||||
|
||||
if (!acc.ContainsKey(label)){
|
||||
acc[label] = new ExpandoObject();
|
||||
acc[label].language = language;
|
||||
acc[label].autoselect = true;
|
||||
acc[label].@default = role == "main";
|
||||
acc[label].playlists = new List<dynamic>();
|
||||
acc[label].uri = string.Empty;
|
||||
}
|
||||
|
||||
var formatted = AddSidxSegmentsToPlaylist(FormatAudioPlaylist(playlist, isAudioOnly), sidxMapping);
|
||||
acc[label].playlists.Add(formatted);
|
||||
|
||||
if (mainPlaylist == null && role == "main"){
|
||||
mainPlaylist = playlist;
|
||||
mainPlaylist.@default = true; // Use '@' to escape reserved keyword
|
||||
}
|
||||
|
||||
return acc;
|
||||
});
|
||||
|
||||
// If no playlists have role "main", mark the first as main
|
||||
if (mainPlaylist == null && formattedPlaylists.Count > 0){
|
||||
var firstLabel = formattedPlaylists.Keys.First();
|
||||
formattedPlaylists[firstLabel].@default = true; // Use '@' to escape reserved keyword
|
||||
}
|
||||
|
||||
return formattedPlaylists;
|
||||
}
|
||||
|
||||
public static IDictionary<string, dynamic> OrganizeVttPlaylists(List<dynamic> playlists, IDictionary<string, dynamic>? sidxMapping = null){
|
||||
sidxMapping ??= new Dictionary<string, dynamic>(); // Ensure sidxMapping is not null
|
||||
|
||||
var organizedPlaylists = playlists.Aggregate(new Dictionary<string, dynamic>(), (acc, playlist) => {
|
||||
var label = playlist.attributes.label ?? playlist.attributes.lang ?? "text";
|
||||
|
||||
if (!acc.ContainsKey(label)){
|
||||
dynamic playlistGroup = new ExpandoObject();
|
||||
playlistGroup.language = label;
|
||||
playlistGroup.@default = false; // '@' is used to escape C# keyword
|
||||
playlistGroup.autoselect = false;
|
||||
playlistGroup.playlists = new List<dynamic>();
|
||||
playlistGroup.uri = string.Empty;
|
||||
|
||||
acc[label] = playlistGroup;
|
||||
}
|
||||
|
||||
acc[label].playlists.Add(AddSidxSegmentsToPlaylist(FormatVttPlaylist(playlist), sidxMapping));
|
||||
|
||||
return acc;
|
||||
});
|
||||
|
||||
return organizedPlaylists;
|
||||
}
|
||||
|
||||
|
||||
public static void AddMediaSequenceValues(List<dynamic> playlists, List<dynamic> timelineStarts){
|
||||
foreach (var playlist in playlists){
|
||||
playlist.mediaSequence = 0;
|
||||
playlist.discontinuitySequence = timelineStarts.FindIndex(ts => ts.timeline == playlist.timeline);
|
||||
|
||||
if (playlist.segments == null) continue;
|
||||
|
||||
for (int i = 0; i < playlist.segments.Count; i++){
|
||||
playlist.segments[i].number = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static List<int> FindIndexes(List<dynamic> list, string key){
|
||||
var indexes = new List<int>();
|
||||
for (int i = 0; i < list.Count; i++){
|
||||
var expandoDict = list[i] as IDictionary<string, object>;
|
||||
if (expandoDict != null && expandoDict.ContainsKey(key) && expandoDict[key] != null){
|
||||
indexes.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
return indexes;
|
||||
}
|
||||
|
||||
public static dynamic AddSidxSegmentsToPlaylist(dynamic playlist, IDictionary<string, dynamic> sidxMapping){
|
||||
string sidxKey = GenerateSidxKey(ObjectUtilities.GetMemberValue(playlist, "sidx"));
|
||||
if (!string.IsNullOrEmpty(sidxKey) && sidxMapping.ContainsKey(sidxKey)){
|
||||
var sidxMatch = sidxMapping[sidxKey];
|
||||
if (sidxMatch != null){
|
||||
SegmentBase.AddSidxSegmentsToPlaylist(playlist, sidxMatch.sidx, playlist.sidx.resolvedUri);
|
||||
}
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
public static List<dynamic> AddSidxSegmentsToPlaylists(List<dynamic> playlists, IDictionary<string, dynamic>? sidxMapping = null){
|
||||
sidxMapping ??= new Dictionary<string, dynamic>();
|
||||
|
||||
if (sidxMapping.Count == 0){
|
||||
return playlists;
|
||||
}
|
||||
|
||||
for (int i = 0; i < playlists.Count; i++){
|
||||
playlists[i] = AddSidxSegmentsToPlaylist(playlists[i], sidxMapping);
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
|
||||
public static string GenerateSidxKey(dynamic sidx){
|
||||
return sidx != null ? $"{sidx.uri}-{UrlType.ByteRangeToString(sidx.byterange)}" : null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,168 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
using System.Xml.Linq;
|
||||
using CRD.Downloader;
|
||||
using CRD.Utils.HLS;
|
||||
using CRD.Utils.Parser;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.Utils;
|
||||
|
||||
public class Segment{
|
||||
public string uri{ get; set; }
|
||||
public double timeline{ get; set; }
|
||||
public double duration{ get; set; }
|
||||
public Map map{ get; set; }
|
||||
|
||||
public ByteRange? byteRange { get; set; }
|
||||
public double? number{ get; set; }
|
||||
public double? presentationTime{ get; set; }
|
||||
}
|
||||
|
||||
public class Map{
|
||||
public string uri { get; set; }
|
||||
|
||||
public ByteRange? byteRange { get; set; }
|
||||
}
|
||||
|
||||
public class PlaylistItem{
|
||||
public string? pssh{ get; set; }
|
||||
public int bandwidth{ get; set; }
|
||||
public List<Segment> segments{ get; set; }
|
||||
}
|
||||
|
||||
public class AudioPlaylist : PlaylistItem{
|
||||
public LanguageItem? language{ get; set; }
|
||||
public bool @default{ get; set; }
|
||||
}
|
||||
|
||||
public class VideoPlaylist : PlaylistItem{
|
||||
public Quality quality{ get; set; }
|
||||
}
|
||||
|
||||
public class VideoItem: VideoPlaylist{
|
||||
public string resolutionText{ get; set; }
|
||||
}
|
||||
|
||||
public class AudioItem: AudioPlaylist{
|
||||
public string resolutionText{ get; set; }
|
||||
}
|
||||
|
||||
public class Quality{
|
||||
public int width{ get; set; }
|
||||
public int height{ get; set; }
|
||||
}
|
||||
|
||||
public class MPDParsed{
|
||||
public Dictionary<string, ServerData> Data{ get; set; }
|
||||
}
|
||||
|
||||
public class ServerData{
|
||||
public List<AudioPlaylist> audio{ get; set; }
|
||||
public List<VideoPlaylist> video{ get; set; }
|
||||
}
|
||||
|
||||
public static class MPDParser{
|
||||
public static MPDParsed Parse(string manifest, LanguageItem? language, string? url){
|
||||
if (!manifest.Contains("BaseURL") && url != null){
|
||||
XDocument doc = XDocument.Parse(manifest);
|
||||
XElement mpd = doc.Element("MPD");
|
||||
mpd.AddFirst(new XElement("BaseURL", url));
|
||||
manifest = doc.ToString();
|
||||
}
|
||||
|
||||
dynamic parsed = DashParser.Parse(manifest);
|
||||
|
||||
MPDParsed ret = new MPDParsed{ Data = new Dictionary<string, ServerData>() };
|
||||
|
||||
foreach (var item in parsed.mediaGroups.AUDIO.audio.Values){
|
||||
foreach (var playlist in item.playlists){
|
||||
var host = new Uri(playlist.resolvedUri).Host;
|
||||
EnsureHostEntryExists(ret, host);
|
||||
|
||||
List<dynamic> segments = playlist.segments;
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(playlist,"sidx") != null && segments.Count == 0){
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
var foundLanguage = Languages.FindLang(Languages.languages.FirstOrDefault(a => a.Code == item.language).CrLocale ?? "unknown");
|
||||
LanguageItem? audioLang = item.language != null ? foundLanguage : (language != null ? language : foundLanguage);
|
||||
|
||||
var pItem = new AudioPlaylist{
|
||||
bandwidth = playlist.attributes.BANDWIDTH,
|
||||
language = audioLang,
|
||||
@default = item.@default,
|
||||
segments = segments.Select(segment => new Segment{
|
||||
duration = segment.duration,
|
||||
map = new Map{uri = segment.map.resolvedUri,byteRange = ObjectUtilities.GetMemberValue(segment.map,"byterange")},
|
||||
number = segment.number,
|
||||
presentationTime = segment.presentationTime,
|
||||
timeline = segment.timeline,
|
||||
uri = segment.resolvedUri,
|
||||
byteRange = ObjectUtilities.GetMemberValue(segment,"byterange")
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var contentProtectionDict = (IDictionary<string, dynamic>)ObjectUtilities.GetMemberValue(playlist,"contentProtection");
|
||||
|
||||
if (contentProtectionDict != null && contentProtectionDict.ContainsKey("com.widevine.alpha") && contentProtectionDict["com.widevine.alpha"].pssh != null)
|
||||
pItem.pssh = ArrayBufferToBase64(contentProtectionDict["com.widevine.alpha"].pssh);
|
||||
|
||||
ret.Data[host].audio.Add(pItem);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var playlist in parsed.playlists){
|
||||
var host = new Uri(playlist.resolvedUri).Host;
|
||||
EnsureHostEntryExists(ret, host);
|
||||
|
||||
List<dynamic> segments = playlist.segments;
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(playlist,"sidx") != null && segments.Count == 0){
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
|
||||
dynamic resolution = ObjectUtilities.GetMemberValue(playlist.attributes,"RESOLUTION");
|
||||
resolution = resolution != null ? resolution : new Quality();
|
||||
|
||||
var pItem = new VideoPlaylist{
|
||||
bandwidth = playlist.attributes.BANDWIDTH,
|
||||
quality = new Quality{height = resolution.height,width = resolution.width},
|
||||
segments = segments.Select(segment => new Segment{
|
||||
duration = segment.duration,
|
||||
map = new Map{uri = segment.map.resolvedUri,byteRange = ObjectUtilities.GetMemberValue(segment.map,"byterange")},
|
||||
number = segment.number,
|
||||
presentationTime = segment.presentationTime,
|
||||
timeline = segment.timeline,
|
||||
uri = segment.resolvedUri,
|
||||
byteRange = ObjectUtilities.GetMemberValue(segment,"byterange")
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var contentProtectionDict = (IDictionary<string, dynamic>)ObjectUtilities.GetMemberValue(playlist,"contentProtection");
|
||||
|
||||
if (contentProtectionDict != null && contentProtectionDict.ContainsKey("com.widevine.alpha") && contentProtectionDict["com.widevine.alpha"].pssh != null)
|
||||
pItem.pssh = ArrayBufferToBase64(contentProtectionDict["com.widevine.alpha"].pssh);
|
||||
|
||||
|
||||
ret.Data[host].video.Add(pItem);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
private static void EnsureHostEntryExists(MPDParsed ret, string host){
|
||||
if (!ret.Data.ContainsKey(host)){
|
||||
ret.Data[host] = new ServerData{ audio = new List<AudioPlaylist>(), video = new List<VideoPlaylist>() };
|
||||
}
|
||||
}
|
||||
|
||||
public static string ArrayBufferToBase64(byte[] buffer){
|
||||
return Convert.ToBase64String(buffer);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
namespace CRD.Utils.Parser;
|
||||
|
||||
public class Errors{
|
||||
public static string INVALID_NUMBER_OF_PERIOD = "INVALID_NUMBER_OF_PERIOD";
|
||||
public static string INVALID_NUMBER_OF_CONTENT_STEERING = "INVALID_NUMBER_OF_CONTENT_STEERING";
|
||||
public static string DASH_EMPTY_MANIFEST = "DASH_EMPTY_MANIFEST";
|
||||
public static string DASH_INVALID_XML = "DASH_INVALID_XML";
|
||||
public static string NO_BASE_URL = "NO_BASE_URL";
|
||||
public static string MISSING_SEGMENT_INFORMATION = "MISSING_SEGMENT_INFORMATION";
|
||||
public static string SEGMENT_TIME_UNSPECIFIED = "SEGMENT_TIME_UNSPECIFIED";
|
||||
public static string UNSUPPORTED_UTC_TIMING_SCHEME = "UNSUPPORTED_UTC_TIMING_SCHEME";
|
||||
|
||||
}
|
|
@ -0,0 +1,460 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
using System.Xml;
|
||||
using Avalonia.Logging;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
|
||||
namespace CRD.Utils.Parser;
|
||||
|
||||
public class InheritAttributes{
|
||||
public static Dictionary<string, string> KeySystemsMap = new Dictionary<string, string>{
|
||||
{ "urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b", "org.w3.clearkey" },
|
||||
{ "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", "com.widevine.alpha" },
|
||||
{ "urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95", "com.microsoft.playready" },
|
||||
{ "urn:uuid:f239e769-efa3-4850-9c16-a903c6932efb", "com.adobe.primetime" },
|
||||
{ "urn:mpeg:dash:mp4protection:2011", "mp4protection" }
|
||||
};
|
||||
|
||||
public static dynamic GenerateKeySystemInformation(List<XmlElement> contentProtectionNodes){
|
||||
var keySystemInfo = new ExpandoObject() as IDictionary<string, object>;
|
||||
|
||||
foreach (var node in contentProtectionNodes){
|
||||
dynamic attributes = ParseAttribute.ParseAttributes(node); // Assume this returns a dictionary
|
||||
var testAttributes = attributes as IDictionary<string, object>;
|
||||
|
||||
if (testAttributes != null && testAttributes.TryGetValue("schemeIdUri", out var attribute)){
|
||||
string? schemeIdUri = attribute.ToString()?.ToLower();
|
||||
if (schemeIdUri != null && KeySystemsMap.TryGetValue(schemeIdUri, out var keySystem)){
|
||||
dynamic info = new ExpandoObject();
|
||||
info.attributes = attributes;
|
||||
|
||||
var psshNode = XMLUtils.FindChildren(node, "cenc:pssh").FirstOrDefault();
|
||||
if (psshNode != null){
|
||||
string pssh = psshNode.InnerText; // Assume this returns the inner text/content
|
||||
if (!string.IsNullOrEmpty(pssh)){
|
||||
info.pssh = DecodeB64ToUint8Array(pssh); // Convert base64 string to byte array
|
||||
}
|
||||
}
|
||||
|
||||
// Instead of using a dictionary key, add the key system directly as a member of the ExpandoObject
|
||||
keySystemInfo[keySystem] = info;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keySystemInfo;
|
||||
}
|
||||
|
||||
private static byte[] DecodeB64ToUint8Array(string base64String){
|
||||
return Convert.FromBase64String(base64String);
|
||||
}
|
||||
|
||||
|
||||
public static string GetContent(XmlElement element) => element.InnerText.Trim();
|
||||
|
||||
public static List<dynamic> BuildBaseUrls(List<dynamic> references, List<XmlElement> baseUrlElements){
|
||||
if (!baseUrlElements.Any()){
|
||||
return references;
|
||||
}
|
||||
|
||||
return references.SelectMany(reference =>
|
||||
baseUrlElements.Select(baseUrlElement => {
|
||||
var initialBaseUrl = GetContent(baseUrlElement);
|
||||
// var resolvedBaseUrl = ResolveUrl(reference.BaseUrl, initialBaseUrl);
|
||||
// var baseUri = new Uri(reference.baseUrl);
|
||||
// string resolvedBaseUrl = new Uri(baseUri, initialBaseUrl).ToString();
|
||||
|
||||
string resolvedBaseUrl = UrlUtils.ResolveUrl(reference.baseUrl, initialBaseUrl);
|
||||
|
||||
dynamic finalBaseUrl = new ExpandoObject();
|
||||
finalBaseUrl.baseUrl = resolvedBaseUrl;
|
||||
|
||||
ObjectUtilities.MergeExpandoObjects(finalBaseUrl, ParseAttribute.ParseAttributes(baseUrlElement));
|
||||
|
||||
if (resolvedBaseUrl != initialBaseUrl && finalBaseUrl.serviceLocation == null && reference.serviceLocation != null){
|
||||
finalBaseUrl.ServiceLocation = reference.ServiceLocation;
|
||||
}
|
||||
|
||||
return finalBaseUrl;
|
||||
})
|
||||
).ToList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public static double? GetPeriodStart(dynamic attributes, dynamic? priorPeriodAttributes, string mpdType){
|
||||
// Summary of period start time calculation from DASH spec section 5.3.2.1
|
||||
//
|
||||
// A period's start is the first period's start + time elapsed after playing all
|
||||
// prior periods to this one. Periods continue one after the other in time (without
|
||||
// gaps) until the end of the presentation.
|
||||
//
|
||||
// The value of Period@start should be:
|
||||
// 1. if Period@start is present: value of Period@start
|
||||
// 2. if previous period exists and it has @duration: previous Period@start +
|
||||
// previous Period@duration
|
||||
// 3. if this is first period and MPD@type is 'static': 0
|
||||
// 4. in all other cases, consider the period an "early available period" (note: not
|
||||
// currently supported)
|
||||
|
||||
var attributesL = attributes as IDictionary<string, object>;
|
||||
// (1)
|
||||
if (attributesL != null && attributesL.ContainsKey("start") && (attributesL["start"] is double || attributesL["start"] is long || attributesL["start"] is float || attributesL["start"] is int)){
|
||||
return (double)attributes.start;
|
||||
}
|
||||
|
||||
var priorPeriodAttributesL = priorPeriodAttributes as IDictionary<string, object>;
|
||||
// (2)
|
||||
if (priorPeriodAttributesL != null && priorPeriodAttributesL.ContainsKey("start") && priorPeriodAttributesL.ContainsKey("duration") &&
|
||||
(priorPeriodAttributesL["start"] is double || priorPeriodAttributesL["start"] is long || priorPeriodAttributesL["start"] is float || priorPeriodAttributesL["start"] is int) &&
|
||||
(priorPeriodAttributesL["duration"] is double || priorPeriodAttributesL["duration"] is long || priorPeriodAttributesL["duration"] is float || priorPeriodAttributesL["duration"] is int)){
|
||||
return (double)priorPeriodAttributes.start + (double)priorPeriodAttributes.duration;
|
||||
}
|
||||
|
||||
// (3)
|
||||
if (priorPeriodAttributesL == null && string.Equals(mpdType, "static", StringComparison.OrdinalIgnoreCase)){
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// (4)
|
||||
// There is currently no logic for calculating the Period@start value if there is
|
||||
// no Period@start or prior Period@start and Period@duration available. This is not made
|
||||
// explicit by the DASH interop guidelines or the DASH spec, however, since there's
|
||||
// nothing about any other resolution strategies, it's implied. Thus, this case should
|
||||
// be considered an early available period, or error, and null should suffice for both
|
||||
// of those cases.
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public class ContentSteeringInfo{
|
||||
public string ServerURL{ get; set; }
|
||||
|
||||
public bool QueryBeforeStart{ get; set; }
|
||||
// Add other properties if needed
|
||||
}
|
||||
|
||||
public static ContentSteeringInfo GenerateContentSteeringInformation(List<XmlElement> contentSteeringNodes){
|
||||
// If there are more than one ContentSteering tags, throw a warning
|
||||
if (contentSteeringNodes.Count > 1){
|
||||
Console.WriteLine("The MPD manifest should contain no more than one ContentSteering tag");
|
||||
}
|
||||
|
||||
// Return null if there are no ContentSteering tags
|
||||
if (contentSteeringNodes.Count == 0){
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract information from the first ContentSteering tag
|
||||
XmlElement firstContentSteeringNode = contentSteeringNodes[0];
|
||||
ContentSteeringInfo infoFromContentSteeringTag = new ContentSteeringInfo{
|
||||
ServerURL = XMLUtils.GetContent(firstContentSteeringNode),
|
||||
// Assuming 'queryBeforeStart' is a boolean attribute
|
||||
QueryBeforeStart = Convert.ToBoolean(firstContentSteeringNode.GetAttribute("queryBeforeStart"))
|
||||
};
|
||||
|
||||
return infoFromContentSteeringTag;
|
||||
}
|
||||
|
||||
private static dynamic CreateExpandoWithTag(string tag){
|
||||
dynamic expando = new ExpandoObject();
|
||||
expando.tag = tag;
|
||||
return expando;
|
||||
}
|
||||
|
||||
public static dynamic GetSegmentInformation(XmlElement adaptationSet){
|
||||
dynamic segmentInfo = new ExpandoObject();
|
||||
|
||||
var segmentTemplate = XMLUtils.FindChildren(adaptationSet, "SegmentTemplate").FirstOrDefault();
|
||||
var segmentList = XMLUtils.FindChildren(adaptationSet, "SegmentList").FirstOrDefault();
|
||||
var segmentUrls = segmentList != null
|
||||
? XMLUtils.FindChildren(segmentList, "SegmentURL").Select(s => ObjectUtilities.MergeExpandoObjects(CreateExpandoWithTag("SegmentURL"), ParseAttribute.ParseAttributes(s))).ToList()
|
||||
: null;
|
||||
var segmentBase = XMLUtils.FindChildren(adaptationSet, "SegmentBase").FirstOrDefault();
|
||||
var segmentTimelineParentNode = segmentList ?? segmentTemplate;
|
||||
var segmentTimeline = segmentTimelineParentNode != null ? XMLUtils.FindChildren(segmentTimelineParentNode, "SegmentTimeline").FirstOrDefault() : null;
|
||||
var segmentInitializationParentNode = segmentList ?? segmentBase ?? segmentTemplate;
|
||||
var segmentInitialization = segmentInitializationParentNode != null ? XMLUtils.FindChildren(segmentInitializationParentNode, "Initialization").FirstOrDefault() : null;
|
||||
|
||||
dynamic template = segmentTemplate != null ? ParseAttribute.ParseAttributes(segmentTemplate) : null;
|
||||
|
||||
if (template != null && segmentInitialization != null){
|
||||
template.initialization = ParseAttribute.ParseAttributes(segmentInitialization);
|
||||
} else if (template != null && template.initialization != null){
|
||||
dynamic init = new ExpandoObject();
|
||||
init.sourceURL = template.initialization;
|
||||
template.initialization = init;
|
||||
}
|
||||
|
||||
segmentInfo.template = template;
|
||||
segmentInfo.segmentTimeline = segmentTimeline != null ? XMLUtils.FindChildren(segmentTimeline, "S").Select(s => ParseAttribute.ParseAttributes(s)).ToList() : null;
|
||||
segmentInfo.list = segmentList != null
|
||||
? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentList), new{ segmentUrls, initialization = ParseAttribute.ParseAttributes(segmentInitialization) })
|
||||
: null;
|
||||
segmentInfo.baseInfo = segmentBase != null ? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentBase), new{ initialization = ParseAttribute.ParseAttributes(segmentInitialization) }) : null;
|
||||
|
||||
// Clean up null entries
|
||||
var dict = (IDictionary<string, object>)segmentInfo;
|
||||
var keys = dict.Keys.ToList();
|
||||
foreach (var key in keys){
|
||||
if (dict[key] == null){
|
||||
dict.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
return segmentInfo;
|
||||
}
|
||||
|
||||
public static List<dynamic> ParseCaptionServiceMetadata(dynamic service){
|
||||
List<dynamic> parsedMetadata = new List<dynamic>();
|
||||
|
||||
var tempTestService = service as IDictionary<string, Object>;
|
||||
|
||||
if (tempTestService == null || !tempTestService.ContainsKey("schemeIdUri")){
|
||||
return parsedMetadata;
|
||||
}
|
||||
|
||||
// 608 captions
|
||||
if (service.schemeIdUri == "urn:scte:dash:cc:cea-608:2015"){
|
||||
var values = service.value is string ? service.value.Split(';') : new string[0];
|
||||
|
||||
foreach (var value in values){
|
||||
dynamic metadata = new ExpandoObject();
|
||||
string channel = null;
|
||||
string language = value;
|
||||
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(value, @"^CC\d=")){
|
||||
var parts = value.Split('=');
|
||||
channel = parts[0];
|
||||
language = parts[1];
|
||||
} else if (System.Text.RegularExpressions.Regex.IsMatch(value, @"^CC\d$")){
|
||||
channel = value;
|
||||
}
|
||||
|
||||
metadata.channel = channel;
|
||||
metadata.language = language;
|
||||
|
||||
parsedMetadata.Add(metadata);
|
||||
}
|
||||
} else if (service.schemeIdUri == "urn:scte:dash:cc:cea-708:2015"){
|
||||
var values = service.value is string ? service.value.Split(';') : new string[0];
|
||||
|
||||
foreach (var value in values){
|
||||
dynamic metadata = new ExpandoObject();
|
||||
metadata.channel = default(string);
|
||||
metadata.language = default(string);
|
||||
metadata.aspectRatio = 1;
|
||||
metadata.easyReader = 0;
|
||||
metadata._3D = 0;
|
||||
|
||||
if (value.Contains("=")){
|
||||
var parts = value.Split('=');
|
||||
var channel = parts[0];
|
||||
var opts = parts.Length > 1 ? parts[1] : "";
|
||||
|
||||
metadata.channel = "SERVICE" + channel;
|
||||
metadata.language = value;
|
||||
|
||||
var options = opts.Split(',');
|
||||
foreach (var opt in options){
|
||||
var optionParts = opt.Split(':');
|
||||
var name = optionParts[0];
|
||||
var val = optionParts.Length > 1 ? optionParts[1] : "";
|
||||
|
||||
switch (name){
|
||||
case "lang":
|
||||
metadata.language = val;
|
||||
break;
|
||||
case "er":
|
||||
metadata.easyReader = Convert.ToInt32(val);
|
||||
break;
|
||||
case "war":
|
||||
metadata.aspectRatio = Convert.ToInt32(val);
|
||||
break;
|
||||
case "3D":
|
||||
metadata._3D = Convert.ToInt32(val);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else{
|
||||
metadata.language = value;
|
||||
}
|
||||
|
||||
parsedMetadata.Add(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
return parsedMetadata;
|
||||
}
|
||||
|
||||
public static List<ExpandoObject> ToRepresentations(dynamic periodAttributes, dynamic periodBaseUrls, dynamic periodSegmentInfo, XmlElement adaptationSet){
|
||||
dynamic adaptationSetAttributes = ParseAttribute.ParseAttributes(adaptationSet);
|
||||
var adaptationSetBaseUrls = BuildBaseUrls(periodBaseUrls, XMLUtils.FindChildren(adaptationSet, "BaseURL"));
|
||||
var role = XMLUtils.FindChildren(adaptationSet, "Role").FirstOrDefault();
|
||||
dynamic roleAttributes = new ExpandoObject();
|
||||
roleAttributes.role = ParseAttribute.ParseAttributes(role);
|
||||
|
||||
dynamic attrs = ObjectUtilities.MergeExpandoObjects(periodAttributes, adaptationSetAttributes);
|
||||
attrs = ObjectUtilities.MergeExpandoObjects(attrs, roleAttributes);
|
||||
|
||||
var accessibility = XMLUtils.FindChildren(adaptationSet, "Accessibility").FirstOrDefault();
|
||||
var captionServices = ParseCaptionServiceMetadata(ParseAttribute.ParseAttributes(accessibility));
|
||||
|
||||
if (captionServices != null){
|
||||
attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ captionServices });
|
||||
}
|
||||
|
||||
XmlElement label = XMLUtils.FindChildren(adaptationSet, "Label").FirstOrDefault();
|
||||
if (label != null && label.ChildNodes.Count > 0){
|
||||
var labelVal = label.ChildNodes[0].ToString().Trim();
|
||||
attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ label = labelVal });
|
||||
}
|
||||
|
||||
var contentProtection = GenerateKeySystemInformation(XMLUtils.FindChildren(adaptationSet, "ContentProtection"));
|
||||
var tempTestContentProtection = contentProtection as IDictionary<string, Object>;
|
||||
if (tempTestContentProtection != null && tempTestContentProtection.Count > 0){
|
||||
dynamic contentProt = new ExpandoObject();
|
||||
contentProt.contentProtection = contentProtection;
|
||||
attrs = ObjectUtilities.MergeExpandoObjects(attrs, contentProt );
|
||||
}
|
||||
|
||||
var segmentInfo = GetSegmentInformation(adaptationSet);
|
||||
var representations = XMLUtils.FindChildren(adaptationSet, "Representation");
|
||||
var adaptationSetSegmentInfo = ObjectUtilities.MergeExpandoObjects(periodSegmentInfo, segmentInfo);
|
||||
|
||||
List<ExpandoObject> list = new List<ExpandoObject>();
|
||||
for (int i = 0; i < representations.Count; i++){
|
||||
List<dynamic> res = InheritBaseUrls(attrs, adaptationSetBaseUrls, adaptationSetSegmentInfo, representations[i]);
|
||||
foreach (dynamic re in res){
|
||||
list.Add(re);
|
||||
}
|
||||
}
|
||||
// return representations.Select(representation => InheritBaseUrls(attrs, adaptationSetBaseUrls, adaptationSetSegmentInfo, representation));
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public static List<dynamic> InheritBaseUrls(dynamic adaptationSetAttributes, dynamic adaptationSetBaseUrls, dynamic adaptationSetSegmentInfo, XmlElement representation){
|
||||
var repBaseUrlElements = XMLUtils.FindChildren(representation, "BaseURL");
|
||||
List<dynamic> repBaseUrls = BuildBaseUrls(adaptationSetBaseUrls, repBaseUrlElements);
|
||||
var attributes = ObjectUtilities.MergeExpandoObjects(adaptationSetAttributes, ParseAttribute.ParseAttributes(representation));
|
||||
var representationSegmentInfo = GetSegmentInformation(representation);
|
||||
|
||||
return repBaseUrls.Select(baseUrl => {
|
||||
dynamic result = new ExpandoObject();
|
||||
result.segmentInfo = ObjectUtilities.MergeExpandoObjects(adaptationSetSegmentInfo, representationSegmentInfo);
|
||||
result.attributes = ObjectUtilities.MergeExpandoObjects(attributes, baseUrl);
|
||||
return result;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
|
||||
private static List<ExpandoObject> ToAdaptationSets(ExpandoObject mpdAttributes, dynamic mpdBaseUrls, dynamic period, int index){
|
||||
dynamic periodBaseUrls = BuildBaseUrls(mpdBaseUrls, XMLUtils.FindChildren(period.node, "BaseURL"));
|
||||
dynamic start = new ExpandoObject();
|
||||
start.periodStart = period.attributes.start;
|
||||
dynamic periodAttributes = ObjectUtilities.MergeExpandoObjects(mpdAttributes, start);
|
||||
|
||||
var tempTestAttributes = period.attributes as IDictionary<string, Object>;
|
||||
if (tempTestAttributes != null && tempTestAttributes.ContainsKey("duration") &&
|
||||
(tempTestAttributes["duration"] is double || tempTestAttributes["duration"] is long || tempTestAttributes["duration"] is float || tempTestAttributes["duration"] is int)){
|
||||
periodAttributes.periodDuration = period.attributes.duration;
|
||||
}
|
||||
|
||||
List<XmlElement> adaptationSets = XMLUtils.FindChildren(period.node, "AdaptationSet");
|
||||
dynamic periodSegmentInfo = GetSegmentInformation(period.node);
|
||||
|
||||
List<ExpandoObject> list = new List<ExpandoObject>();
|
||||
|
||||
for (int i = 0; i < adaptationSets.Count; i++){
|
||||
List<ExpandoObject> res = ToRepresentations(periodAttributes, periodBaseUrls, periodSegmentInfo, adaptationSets[i]);
|
||||
foreach (dynamic re in res){
|
||||
list.Add(re);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return list;
|
||||
|
||||
|
||||
// return adaptationSets.Select(adaptationSet =>
|
||||
// ToRepresentations(periodAttributes, periodBaseUrls, periodSegmentInfo, adaptationSet));
|
||||
}
|
||||
|
||||
public static ManifestInfo InheritAttributesFun(XmlElement mpd, Dictionary<string, object>? options = null){
|
||||
if (options == null)
|
||||
options = new Dictionary<string, object>();
|
||||
|
||||
string manifestUri = options.ContainsKey("manifestUri") ? (string)options["manifestUri"] : string.Empty;
|
||||
long NOW = options.ContainsKey("NOW") ? (long)options["NOW"] : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
int clientOffset = options.ContainsKey("clientOffset") ? (int)options["clientOffset"] : 0;
|
||||
Action eventHandler = options.ContainsKey("eventHandler") ? (Action)options["eventHandler"] : () => { };
|
||||
|
||||
List<XmlElement> periodNodes = XMLUtils.FindChildren(mpd, "Period");
|
||||
|
||||
if (periodNodes.Count == 0){
|
||||
throw new Exception(Errors.INVALID_NUMBER_OF_PERIOD);
|
||||
}
|
||||
|
||||
List<XmlElement> locations = XMLUtils.FindChildren(mpd, "Location");
|
||||
dynamic mpdAttributes = ParseAttribute.ParseAttributes(mpd);
|
||||
dynamic baseUrl = new ExpandoObject();
|
||||
baseUrl.baseUrl = manifestUri;
|
||||
dynamic mpdBaseUrls = BuildBaseUrls(new List<dynamic>{ baseUrl }, XMLUtils.FindChildren(mpd, "BaseUrl"));
|
||||
List<XmlElement> contentSteeringNodes = XMLUtils.FindChildren(mpd, "ContentSteering");
|
||||
|
||||
// See DASH spec section 5.3.1.2, Semantics of MPD element. Default type to 'static'.
|
||||
|
||||
ObjectUtilities.SetAttributeWithDefault(mpdAttributes, "type", "static");
|
||||
ObjectUtilities.SetFieldFromOrToDefault(mpdAttributes, "sourceDuration", "mediaPresentationDuration", 0);
|
||||
mpdAttributes.NOW = NOW;
|
||||
mpdAttributes.clientOffset = clientOffset;
|
||||
|
||||
if (locations.Count > 0){
|
||||
mpdAttributes.locations = locations.Cast<XmlElement>().Select(location => location.InnerText).ToList();
|
||||
}
|
||||
|
||||
List<ExpandoObject> periods = new List<ExpandoObject>();
|
||||
|
||||
for (int i = 0; i < periodNodes.Count; i++){
|
||||
XmlElement periodNode = periodNodes[i];
|
||||
dynamic attributes = ParseAttribute.ParseAttributes(periodNode);
|
||||
|
||||
int getIndex = i - 1;
|
||||
|
||||
dynamic? priorPeriod = null;
|
||||
if (getIndex >= 0 && getIndex < periods.Count){
|
||||
priorPeriod = periods[getIndex];
|
||||
}
|
||||
|
||||
attributes.start = GetPeriodStart(attributes, priorPeriod, mpdAttributes.type);
|
||||
|
||||
dynamic finalPeriod = new ExpandoObject();
|
||||
finalPeriod.node = periodNode;
|
||||
finalPeriod.attributes = attributes;
|
||||
|
||||
periods.Add(finalPeriod);
|
||||
}
|
||||
|
||||
|
||||
List<ExpandoObject> representationInfo = new List<ExpandoObject>();
|
||||
|
||||
for (int i = 0; i < periods.Count; i++){
|
||||
List<ExpandoObject> result = ToAdaptationSets(mpdAttributes, mpdBaseUrls, periods[i], i);
|
||||
foreach (dynamic re in result){
|
||||
representationInfo.Add(re);
|
||||
}
|
||||
}
|
||||
|
||||
return new ManifestInfo{
|
||||
locations = ObjectUtilities.GetAttributeWithDefault(mpdAttributes, "locations", null),
|
||||
contentSteeringInfo = GenerateContentSteeringInformation(contentSteeringNodes.Cast<XmlElement>().ToList()),
|
||||
representationInfo = representationInfo,
|
||||
// eventStream = periods.SelectMany(period => ToEventStream(period)).ToList()
|
||||
};
|
||||
}
|
||||
}
|
|
@ -0,0 +1,321 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
using System.Xml;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
|
||||
namespace CRD.Utils.Parser;
|
||||
|
||||
public class ParseAttribute{
|
||||
public static Dictionary<string, Func<string, object>> ParsersDictionary = new Dictionary<string, Func<string, object>>{
|
||||
{ "mediaPresentationDuration", MediaPresentationDuration },
|
||||
{ "availabilityStartTime", AvailabilityStartTime },
|
||||
{ "minimumUpdatePeriod", MinimumUpdatePeriod },
|
||||
{ "suggestedPresentationDelay", SuggestedPresentationDelay },
|
||||
{ "type", Type },
|
||||
{ "timeShiftBufferDepth", TimeShiftBufferDepth },
|
||||
{ "start", Start },
|
||||
{ "width", Width },
|
||||
{ "height", Height },
|
||||
{ "bandwidth", Bandwidth },
|
||||
{ "frameRate", FrameRate },
|
||||
{ "startNumber", StartNumber },
|
||||
{ "timescale", Timescale },
|
||||
{ "presentationTimeOffset", PresentationTimeOffset },
|
||||
{ "duration", Duration },
|
||||
{ "d", D },
|
||||
{ "t", T },
|
||||
{ "r", R },
|
||||
{ "presentationTime", PresentationTime },
|
||||
{ "DEFAULT", DefaultParser }
|
||||
};
|
||||
|
||||
public static object MediaPresentationDuration(string value) => DurationParser.ParseDuration(value);
|
||||
public static object AvailabilityStartTime(string value) => DurationParser.ParseDate(value) / 1000;
|
||||
public static object MinimumUpdatePeriod(string value) => DurationParser.ParseDuration(value);
|
||||
public static object SuggestedPresentationDelay(string value) => DurationParser.ParseDuration(value);
|
||||
public static object Type(string value) => value;
|
||||
public static object TimeShiftBufferDepth(string value) => DurationParser.ParseDuration(value);
|
||||
public static object Start(string value) => DurationParser.ParseDuration(value);
|
||||
public static object Width(string value) => int.Parse(value);
|
||||
public static object Height(string value) => int.Parse(value);
|
||||
public static object Bandwidth(string value) => int.Parse(value);
|
||||
public static object FrameRate(string value) => DivisionValueParser.ParseDivisionValue(value);
|
||||
public static object StartNumber(string value) => int.Parse(value);
|
||||
public static object Timescale(string value) => int.Parse(value);
|
||||
public static object PresentationTimeOffset(string value) => int.Parse(value);
|
||||
|
||||
public static object Duration(string value){
|
||||
if (int.TryParse(value, out int parsedValue)){
|
||||
return parsedValue;
|
||||
}
|
||||
|
||||
return DurationParser.ParseDuration(value);
|
||||
}
|
||||
|
||||
public static object D(string value) => int.Parse(value);
|
||||
public static object T(string value) => int.Parse(value);
|
||||
public static object R(string value) => int.Parse(value);
|
||||
public static object PresentationTime(string value) => int.Parse(value);
|
||||
public static object DefaultParser(string value) => value;
|
||||
|
||||
// public static Dictionary<string, object> ParseAttributes(XmlNode el)
|
||||
// {
|
||||
// if (!(el != null && el.Attributes != null))
|
||||
// {
|
||||
// return new Dictionary<string, object>();
|
||||
// }
|
||||
//
|
||||
// return el.Attributes.Cast<XmlAttribute>()
|
||||
// .ToDictionary(attr => attr.Name, attr =>
|
||||
// {
|
||||
// Func<string, object> parseFn;
|
||||
// if (ParsersDictionary.TryGetValue(attr.Name, out parseFn))
|
||||
// {
|
||||
// return parseFn(attr.Value);
|
||||
// }
|
||||
// return DefaultParser(attr.Value);
|
||||
// });
|
||||
// }
|
||||
|
||||
public static dynamic ParseAttributes(XmlNode el){
|
||||
var expandoObj = new ExpandoObject() as IDictionary<string, object>;
|
||||
|
||||
if (el != null && el.Attributes != null){
|
||||
foreach (XmlAttribute attr in el.Attributes){
|
||||
Func<string, object> parseFn;
|
||||
if (ParsersDictionary.TryGetValue(attr.Name, out parseFn)){
|
||||
expandoObj[attr.Name] = parseFn(attr.Value);
|
||||
} else{
|
||||
expandoObj[attr.Name] = DefaultParser(attr.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return expandoObj;
|
||||
}
|
||||
}
|
||||
|
||||
// public class ParsedAttributes{
|
||||
// public double MediaPresentationDuration{ get; set; }
|
||||
// public long AvailabilityStartTime{ get; set; }
|
||||
// public double MinimumUpdatePeriod{ get; set; }
|
||||
// public double SuggestedPresentationDelay{ get; set; }
|
||||
// public string Type{ get; set; }
|
||||
// public double TimeShiftBufferDepth{ get; set; }
|
||||
// public double? Start{ get; set; }
|
||||
// public int Width{ get; set; }
|
||||
// public int Height{ get; set; }
|
||||
// public int Bandwidth{ get; set; }
|
||||
// public double FrameRate{ get; set; }
|
||||
// public int StartNumber{ get; set; }
|
||||
// public int Timescale{ get; set; }
|
||||
// public int PresentationTimeOffset{ get; set; }
|
||||
// public double? Duration{ get; set; }
|
||||
// public int D{ get; set; }
|
||||
// public int T{ get; set; }
|
||||
// public int R{ get; set; }
|
||||
// public int PresentationTime{ get; set; }
|
||||
//
|
||||
// public int clientOffset{ get; set; }
|
||||
//
|
||||
// public long NOW{ get; set; }
|
||||
// public double sourceDuration{ get; set; }
|
||||
// public List<string> locations{ get; set; }
|
||||
// public string baseUrl{ get; set; }
|
||||
// public string? serviceLocation{ get; set; }
|
||||
//
|
||||
// public ParsedAttributes(){
|
||||
//
|
||||
// }
|
||||
//
|
||||
// public ParsedAttributes(
|
||||
// double mediaPresentationDuration,
|
||||
// long availabilityStartTime,
|
||||
// double minimumUpdatePeriod,
|
||||
// double suggestedPresentationDelay,
|
||||
// string type,
|
||||
// double timeShiftBufferDepth,
|
||||
// double? start,
|
||||
// int width,
|
||||
// int height,
|
||||
// int bandwidth,
|
||||
// double frameRate,
|
||||
// int startNumber,
|
||||
// int timescale,
|
||||
// int presentationTimeOffset,
|
||||
// double? duration,
|
||||
// int d,
|
||||
// int t,
|
||||
// int r,
|
||||
// int presentationTime){
|
||||
// MediaPresentationDuration = mediaPresentationDuration;
|
||||
// AvailabilityStartTime = availabilityStartTime;
|
||||
// MinimumUpdatePeriod = minimumUpdatePeriod;
|
||||
// SuggestedPresentationDelay = suggestedPresentationDelay;
|
||||
// Type = type;
|
||||
// TimeShiftBufferDepth = timeShiftBufferDepth;
|
||||
// Start = start;
|
||||
// Width = width;
|
||||
// Height = height;
|
||||
// Bandwidth = bandwidth;
|
||||
// FrameRate = frameRate;
|
||||
// StartNumber = startNumber;
|
||||
// Timescale = timescale;
|
||||
// PresentationTimeOffset = presentationTimeOffset;
|
||||
// Duration = duration;
|
||||
// D = d;
|
||||
// T = t;
|
||||
// R = r;
|
||||
// PresentationTime = presentationTime;
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// public class ParseAttribute{
|
||||
// public static Dictionary<string, Func<string, object>> ParsersDictionary = new Dictionary<string, Func<string, object>>{
|
||||
// { "mediaPresentationDuration", MediaPresentationDuration },
|
||||
// { "availabilityStartTime", AvailabilityStartTime },
|
||||
// { "minimumUpdatePeriod", MinimumUpdatePeriod },
|
||||
// { "suggestedPresentationDelay", SuggestedPresentationDelay },
|
||||
// { "type", Type },
|
||||
// { "timeShiftBufferDepth", TimeShiftBufferDepth },
|
||||
// { "start", Start },
|
||||
// { "width", Width },
|
||||
// { "height", Height },
|
||||
// { "bandwidth", Bandwidth },
|
||||
// { "frameRate", FrameRate },
|
||||
// { "startNumber", StartNumber },
|
||||
// { "timescale", Timescale },
|
||||
// { "presentationTimeOffset", PresentationTimeOffset },
|
||||
// { "duration", Duration },
|
||||
// { "d", D },
|
||||
// { "t", T },
|
||||
// { "r", R },
|
||||
// { "presentationTime", PresentationTime },
|
||||
// { "DEFAULT", DefaultParser }
|
||||
// };
|
||||
//
|
||||
// public static object MediaPresentationDuration(string value) => DurationParser.ParseDuration(value);
|
||||
// public static object AvailabilityStartTime(string value) => DurationParser.ParseDate(value) / 1000;
|
||||
// public static object MinimumUpdatePeriod(string value) => DurationParser.ParseDuration(value);
|
||||
// public static object SuggestedPresentationDelay(string value) => DurationParser.ParseDuration(value);
|
||||
// public static object Type(string value) => value;
|
||||
// public static object TimeShiftBufferDepth(string value) => DurationParser.ParseDuration(value);
|
||||
// public static object Start(string value) => DurationParser.ParseDuration(value);
|
||||
// public static object Width(string value) => int.Parse(value);
|
||||
// public static object Height(string value) => int.Parse(value);
|
||||
// public static object Bandwidth(string value) => int.Parse(value);
|
||||
// public static object FrameRate(string value) => DivisionValueParser.ParseDivisionValue(value);
|
||||
// public static object StartNumber(string value) => int.Parse(value);
|
||||
// public static object Timescale(string value) => int.Parse(value);
|
||||
// public static object PresentationTimeOffset(string value) => int.Parse(value);
|
||||
//
|
||||
// public static object Duration(string value){
|
||||
// if (int.TryParse(value, out int parsedValue)){
|
||||
// return parsedValue;
|
||||
// }
|
||||
//
|
||||
// return DurationParser.ParseDuration(value);
|
||||
// }
|
||||
//
|
||||
// public static object D(string value) => int.Parse(value);
|
||||
// public static object T(string value) => int.Parse(value);
|
||||
// public static object R(string value) => int.Parse(value);
|
||||
// public static object PresentationTime(string value) => int.Parse(value);
|
||||
// public static object DefaultParser(string value) => value;
|
||||
//
|
||||
// public static ParsedAttributes ParseAttributes(XmlNode el){
|
||||
// if (!(el != null && el.Attributes != null)){
|
||||
// return new ParsedAttributes(0, 0, 0, 0, "", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
||||
// }
|
||||
// double mediaPresentationDuration = 0;
|
||||
// long availabilityStartTime = 0;
|
||||
// double minimumUpdatePeriod = 0;
|
||||
// double suggestedPresentationDelay = 0;
|
||||
// string type = "";
|
||||
// double timeShiftBufferDepth = 0;
|
||||
// double? start = null;
|
||||
// int width = 0;
|
||||
// int height = 0;
|
||||
// int bandwidth = 0;
|
||||
// double frameRate = 0;
|
||||
// int startNumber = 0;
|
||||
// int timescale = 0;
|
||||
// int presentationTimeOffset = 0;
|
||||
// double? duration = null;
|
||||
// int d = 0;
|
||||
// int t = 0;
|
||||
// int r = 0;
|
||||
// int presentationTime = 0;
|
||||
//
|
||||
// foreach (XmlAttribute attr in el.Attributes){
|
||||
// Func<string, object> parseFn;
|
||||
// if (ParsersDictionary.TryGetValue(attr.Name, out parseFn)){
|
||||
// switch (attr.Name){
|
||||
// case "mediaPresentationDuration":
|
||||
// mediaPresentationDuration = (double)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "availabilityStartTime":
|
||||
// availabilityStartTime = (long)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "minimumUpdatePeriod":
|
||||
// minimumUpdatePeriod = (double)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "suggestedPresentationDelay":
|
||||
// suggestedPresentationDelay = (double)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "type":
|
||||
// type = (string)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "timeShiftBufferDepth":
|
||||
// timeShiftBufferDepth = (double)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "start":
|
||||
// start = (double)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "width":
|
||||
// width = (int)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "height":
|
||||
// height = (int)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "bandwidth":
|
||||
// bandwidth = (int)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "frameRate":
|
||||
// frameRate = (double)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "startNumber":
|
||||
// startNumber = (int)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "timescale":
|
||||
// timescale = (int)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "presentationTimeOffset":
|
||||
// presentationTimeOffset = (int)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "duration":
|
||||
// duration = (double)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "d":
|
||||
// d = (int)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "t":
|
||||
// t = (int)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "r":
|
||||
// r = (int)parseFn(attr.Value);
|
||||
// break;
|
||||
// case "presentationTime":
|
||||
// presentationTime = (int)parseFn(attr.Value);
|
||||
// break;
|
||||
// // Add cases for other attributes
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return new ParsedAttributes(mediaPresentationDuration, availabilityStartTime, minimumUpdatePeriod, suggestedPresentationDelay, type, timeShiftBufferDepth, start, width, height, bandwidth, frameRate, startNumber,
|
||||
// timescale, presentationTimeOffset, duration, d, t, r, presentationTime);
|
||||
// }
|
||||
// }
|
|
@ -0,0 +1,131 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace CRD.Utils.Parser;
|
||||
|
||||
public class PlaylistMerge{
|
||||
public static List<dynamic> Union(List<List<dynamic>> lists, Func<dynamic, dynamic> keyFunction){
|
||||
var uniqueElements = new Dictionary<dynamic, dynamic>();
|
||||
|
||||
foreach (var list in lists){
|
||||
foreach (var element in list){
|
||||
dynamic key = keyFunction(element);
|
||||
if (!uniqueElements.ContainsKey(key)){
|
||||
uniqueElements[key] = element;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the values as a list
|
||||
return uniqueElements.Values.ToList();
|
||||
}
|
||||
|
||||
public static List<dynamic> GetUniqueTimelineStarts(List<List<dynamic>> timelineStarts){
|
||||
var uniqueStarts = Union(timelineStarts, el => el.timeline);
|
||||
|
||||
// Sort the results based on the timeline
|
||||
return uniqueStarts.OrderBy(el => el.timeline).ToList();
|
||||
}
|
||||
|
||||
public static dynamic PositionManifestOnTimeline(dynamic oldManifest, dynamic newManifest){
|
||||
List<dynamic> oldPlaylists = ((List<dynamic>)oldManifest.playlists).AddRange(GetMediaGroupPlaylists(oldManifest)).ToList();
|
||||
List<dynamic> newPlaylists = ((List<dynamic>)newManifest.playlists).AddRange(GetMediaGroupPlaylists(newManifest)).ToList();
|
||||
|
||||
newManifest.timelineStarts = GetUniqueTimelineStarts(new List<List<dynamic>>{ oldManifest.timelineStarts, newManifest.timelineStarts });
|
||||
|
||||
// Assuming UpdateSequenceNumbers is implemented elsewhere
|
||||
UpdateSequenceNumbers(oldPlaylists, newPlaylists, newManifest.timelineStarts);
|
||||
|
||||
return newManifest;
|
||||
}
|
||||
|
||||
private static readonly string[] SupportedMediaTypes ={ "AUDIO", "SUBTITLES" };
|
||||
|
||||
public static List<dynamic> GetMediaGroupPlaylists(dynamic manifest){
|
||||
var mediaGroupPlaylists = new List<dynamic>();
|
||||
|
||||
foreach (var mediaType in SupportedMediaTypes){
|
||||
var mediaGroups = (IDictionary<string, object>)manifest.mediaGroups[mediaType];
|
||||
foreach (var groupKey in mediaGroups.Keys){
|
||||
var labels = (IDictionary<string, object>)mediaGroups[groupKey];
|
||||
foreach (var labelKey in labels.Keys){
|
||||
var properties = (dynamic)labels[labelKey];
|
||||
if (properties.playlists != null){
|
||||
mediaGroupPlaylists.AddRange(properties.playlists);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mediaGroupPlaylists;
|
||||
}
|
||||
|
||||
private const double TimeFudge = 1 / (double)60;
|
||||
|
||||
public static void UpdateSequenceNumbers(List<dynamic> oldPlaylists, List<dynamic> newPlaylists, List<dynamic> timelineStarts){
|
||||
foreach (dynamic playlist in newPlaylists){
|
||||
playlist.discontinuitySequence = timelineStarts.FindIndex(ts => ts.timeline == playlist.timeline);
|
||||
|
||||
dynamic oldPlaylist = FindPlaylistWithName(oldPlaylists, playlist.attributes.NAME);
|
||||
|
||||
if (oldPlaylist == null){
|
||||
// New playlist, no further processing needed
|
||||
continue;
|
||||
}
|
||||
|
||||
if (playlist.sidx != null){
|
||||
// Skip playlists with sidx
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!playlist.segments.Any()){
|
||||
// No segments to process
|
||||
continue;
|
||||
}
|
||||
|
||||
dynamic firstNewSegment = playlist.segments[0];
|
||||
List<dynamic> segmentList = oldPlaylist.segments;
|
||||
dynamic oldMatchingSegmentIndex = segmentList.FindIndex(
|
||||
oldSegment => Math.Abs(oldSegment.presentationTime - firstNewSegment.presentationTime) < TimeFudge
|
||||
);
|
||||
|
||||
if (oldMatchingSegmentIndex == -1){
|
||||
UpdateMediaSequenceForPlaylist(playlist, oldPlaylist.mediaSequence + oldPlaylist.segments.Count);
|
||||
playlist.segments[0].discontinuity = true;
|
||||
playlist.discontinuityStarts.Insert(0, 0);
|
||||
|
||||
if ((!oldPlaylist.segments.Any() && playlist.timeline > oldPlaylist.timeline) ||
|
||||
(oldPlaylist.segments.Any() && playlist.timeline > oldPlaylist.segments.Last().timeline)){
|
||||
playlist.discontinuitySequence--;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
var oldMatchingSegment = oldPlaylist.segments[oldMatchingSegmentIndex];
|
||||
|
||||
if (oldMatchingSegment.discontinuity && !firstNewSegment.discontinuity){
|
||||
firstNewSegment.discontinuity = true;
|
||||
playlist.discontinuityStarts.Insert(0, 0);
|
||||
playlist.discontinuitySequence--;
|
||||
}
|
||||
|
||||
UpdateMediaSequenceForPlaylist(playlist, oldPlaylist.segments[oldMatchingSegmentIndex].number);
|
||||
}
|
||||
}
|
||||
|
||||
public static dynamic FindPlaylistWithName(List<dynamic> playlists, string name){
|
||||
return playlists.FirstOrDefault(playlist => playlist.attributes.NAME == name);
|
||||
}
|
||||
|
||||
public static void UpdateMediaSequenceForPlaylist(dynamic playlist, int mediaSequence){
|
||||
playlist.mediaSequence = mediaSequence;
|
||||
|
||||
if (playlist.segments == null) return;
|
||||
|
||||
for (int index = 0; index < playlist.segments.Count; index++){
|
||||
playlist.segments[index].number = playlist.mediaSequence + index;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
using CRD.Utils.Parser.Segments;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
|
||||
namespace CRD.Utils.Parser;
|
||||
|
||||
public class ToPlaylistsClass{
|
||||
public static List<dynamic> ToPlaylists(IEnumerable<dynamic> representations){
|
||||
return representations.Select(GenerateSegments).ToList();
|
||||
}
|
||||
|
||||
public static dynamic GenerateSegments(dynamic input){
|
||||
dynamic segmentAttributes = new ExpandoObject();
|
||||
Func<dynamic, List<dynamic>, List<dynamic>> segmentsFn = null;
|
||||
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(input.segmentInfo,"template") != null){
|
||||
segmentsFn = SegmentTemplate.SegmentsFromTemplate;
|
||||
segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.template);
|
||||
} else if (ObjectUtilities.GetMemberValue(input.segmentInfo,"@base") != null){
|
||||
//TODO
|
||||
Console.WriteLine("UNTESTED PARSING");
|
||||
segmentsFn = SegmentBase.SegmentsFromBase;
|
||||
segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.@base);
|
||||
} else if (ObjectUtilities.GetMemberValue(input.segmentInfo,"list") != null){
|
||||
//TODO
|
||||
Console.WriteLine("UNTESTED PARSING");
|
||||
segmentsFn = SegmentList.SegmentsFromList;
|
||||
segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.list);
|
||||
}
|
||||
|
||||
dynamic segmentsInfo = new ExpandoObject();
|
||||
segmentsInfo.attributes = input.attributes;
|
||||
|
||||
if (segmentsFn == null){
|
||||
return segmentsInfo;
|
||||
}
|
||||
|
||||
List<dynamic> segments = segmentsFn(segmentAttributes, input.segmentInfo.segmentTimeline);
|
||||
|
||||
// Duration processing
|
||||
if (ObjectUtilities.GetMemberValue(segmentAttributes,"duration") != null){
|
||||
int timescale = ObjectUtilities.GetMemberValue(segmentAttributes,"timescale") ?? 1;
|
||||
segmentAttributes.duration = ObjectUtilities.GetMemberValue(segmentAttributes,"duration") / timescale;
|
||||
} else if (segments.Any()){
|
||||
segmentAttributes.duration = segments.Max(segment => Math.Ceiling(ObjectUtilities.GetMemberValue(segment,"duration")));
|
||||
} else{
|
||||
segmentAttributes.duration = 0;
|
||||
}
|
||||
|
||||
segmentsInfo.attributes = segmentAttributes;
|
||||
segmentsInfo.segments = segments;
|
||||
|
||||
// sidx box handling
|
||||
if (ObjectUtilities.GetMemberValue(input.segmentInfo,"base") != null && ObjectUtilities.GetMemberValue(segmentAttributes,"indexRange") != null){
|
||||
segmentsInfo.sidx = segments.FirstOrDefault();
|
||||
segmentsInfo.segments = new List<dynamic>();
|
||||
}
|
||||
|
||||
return segmentsInfo;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace CRD.Utils.Parser.Segments;
|
||||
|
||||
public class DurationTimeParser{
|
||||
public static int? ParseEndNumber(string endNumber){
|
||||
if (!int.TryParse(endNumber, out var parsedEndNumber)){
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedEndNumber;
|
||||
}
|
||||
|
||||
public static dynamic GetSegmentRangeStatic(dynamic attributes){
|
||||
int timescale = attributes.timescale ?? 1;
|
||||
double segmentDuration = (double)attributes.duration / timescale;
|
||||
int? endNumber = ParseEndNumber(attributes.endNumber as string);
|
||||
|
||||
if (endNumber.HasValue){
|
||||
return new{ start = 0, end = endNumber.Value };
|
||||
}
|
||||
|
||||
if (attributes.periodDuration is double periodDuration){
|
||||
return new{ start = 0, end = (int)(periodDuration / segmentDuration) };
|
||||
}
|
||||
|
||||
return new{ start = 0, end = (int)(attributes.sourceDuration / segmentDuration) };
|
||||
}
|
||||
|
||||
public static dynamic GetSegmentRangeDynamic(dynamic attributes){
|
||||
long now = (attributes.NOW + attributes.clientOffset) / 1000;
|
||||
long periodStartWC = attributes.availabilityStartTime + attributes.periodStart;
|
||||
long periodEndWC = now + attributes.minimumUpdatePeriod;
|
||||
long periodDuration = periodEndWC - periodStartWC;
|
||||
int timescale = attributes.timescale ?? 1;
|
||||
int segmentCount = (int)Math.Ceiling(periodDuration * timescale / (double)attributes.duration);
|
||||
int availableStart = (int)Math.Floor((now - periodStartWC - attributes.timeShiftBufferDepth) * timescale / (double)attributes.duration);
|
||||
int availableEnd = (int)Math.Floor((now - periodStartWC) * timescale / (double)attributes.duration);
|
||||
|
||||
int? endNumber = ParseEndNumber(attributes.endNumber as string);
|
||||
int end = endNumber.HasValue ? endNumber.Value : Math.Min(segmentCount, availableEnd);
|
||||
|
||||
return new{ start = Math.Max(0, availableStart), end = end };
|
||||
}
|
||||
|
||||
public static List<dynamic> ToSegments(dynamic attributes, int number){
|
||||
int timescale = attributes.timescale ?? 1;
|
||||
long periodStart = attributes.periodStart;
|
||||
int startNumber = attributes.startNumber ?? 1;
|
||||
|
||||
return new List<dynamic>{
|
||||
new{
|
||||
number = startNumber + number,
|
||||
duration = (double)attributes.duration / timescale,
|
||||
timeline = periodStart,
|
||||
time = number * attributes.duration
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static IEnumerable<dynamic> ParseByDuration(dynamic attributes){
|
||||
var type = (string)attributes.type;
|
||||
var rangeFunction = type == "static" ? (Func<dynamic, dynamic>)GetSegmentRangeStatic : GetSegmentRangeDynamic;
|
||||
dynamic times = rangeFunction(attributes);
|
||||
List<int> d = Range(times.start, times.end - times.start);
|
||||
List<dynamic> segments = d.Select(number => ToSegments(attributes, number)).ToList();
|
||||
|
||||
|
||||
// Adjust the duration of the last segment for static type
|
||||
if (type == "static" && segments.Any()){
|
||||
var lastSegmentIndex = segments.Count - 1;
|
||||
double sectionDuration = attributes.periodDuration is double periodDuration ? periodDuration : attributes.sourceDuration;
|
||||
segments[lastSegmentIndex].duration = sectionDuration - ((double)attributes.duration / (attributes.timescale ?? 1) * lastSegmentIndex);
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
public static List<int> Range(int start, int end){
|
||||
List<int> res = new List<int>();
|
||||
for (int i = start; i < end; i++){
|
||||
res.Add(i);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
using System.Numerics;
|
||||
|
||||
namespace CRD.Utils.Parser.Segments;
|
||||
|
||||
public class SegmentBase{
|
||||
public static List<dynamic> SegmentsFromBase(dynamic attributes, List<dynamic> segmentTimeline){
|
||||
if (attributes.baseUrl == null){
|
||||
throw new Exception("NO_BASE_URL");
|
||||
}
|
||||
|
||||
var initialization = attributes.initialization ?? new ExpandoObject();
|
||||
var sourceDuration = attributes.sourceDuration;
|
||||
var indexRange = attributes.indexRange ?? "";
|
||||
var periodStart = attributes.periodStart;
|
||||
var presentationTime = attributes.presentationTime;
|
||||
var number = attributes.number ?? 0;
|
||||
var duration = attributes.duration;
|
||||
|
||||
dynamic initSegment = UrlType.UrlTypeToSegment(new{
|
||||
baseUrl = attributes.baseUrl,
|
||||
source = initialization.sourceURL,
|
||||
range = initialization.range
|
||||
});
|
||||
|
||||
dynamic segment = UrlType.UrlTypeToSegment(new{
|
||||
baseUrl = attributes.baseUrl,
|
||||
source = attributes.baseUrl,
|
||||
indexRange = indexRange
|
||||
});
|
||||
|
||||
segment.map = initSegment;
|
||||
|
||||
if (duration != null){
|
||||
var segmentTimeInfo = DurationTimeParser.ParseByDuration(attributes);
|
||||
if (segmentTimeInfo.Count > 0){
|
||||
segment.duration = segmentTimeInfo[0].duration;
|
||||
segment.timeline = segmentTimeInfo[0].timeline;
|
||||
}
|
||||
} else if (sourceDuration != null){
|
||||
segment.duration = sourceDuration;
|
||||
segment.timeline = periodStart;
|
||||
}
|
||||
|
||||
segment.presentationTime = presentationTime ?? periodStart;
|
||||
segment.number = number;
|
||||
|
||||
return new List<dynamic>{ segment };
|
||||
}
|
||||
|
||||
|
||||
public static dynamic AddSidxSegmentsToPlaylist(dynamic playlist, dynamic sidx, string baseUrl){
|
||||
// Assume dynamic objects like sidx have properties similar to JavaScript objects
|
||||
var initSegment = playlist.sidx.ContainsKey("map") ? playlist.sidx.map : null;
|
||||
var sourceDuration = playlist.sidx.duration;
|
||||
var timeline = playlist.timeline ?? 0;
|
||||
dynamic sidxByteRange = playlist.sidx.byterange;
|
||||
BigInteger sidxEnd = new BigInteger((long)sidxByteRange.offset + (long)sidxByteRange.length);
|
||||
var timescale = (long)sidx.timescale;
|
||||
var mediaReferences = ((List<dynamic>)sidx.references).Where(r => r.referenceType != 1).ToList();
|
||||
var segments = new List<dynamic>();
|
||||
var type = playlist.endList ? "static" : "dynamic";
|
||||
var periodStart = (long)playlist.sidx.timeline;
|
||||
BigInteger presentationTime = new BigInteger(periodStart);
|
||||
var number = playlist.mediaSequence ?? 0;
|
||||
|
||||
BigInteger startIndex;
|
||||
if (sidx.firstOffset is BigInteger){
|
||||
startIndex = sidxEnd + (BigInteger)sidx.firstOffset;
|
||||
} else{
|
||||
startIndex = sidxEnd + new BigInteger((long)sidx.firstOffset);
|
||||
}
|
||||
|
||||
foreach (var reference in mediaReferences){
|
||||
var size = (long)reference.referencedSize;
|
||||
var duration = (long)reference.subsegmentDuration;
|
||||
BigInteger endIndex = startIndex + new BigInteger(size) - BigInteger.One;
|
||||
var indexRange = $"{startIndex}-{endIndex}";
|
||||
|
||||
dynamic attributes = new ExpandoObject();
|
||||
attributes.baseUrl = baseUrl;
|
||||
attributes.timescale = timescale;
|
||||
attributes.timeline = timeline;
|
||||
attributes.periodStart = periodStart;
|
||||
attributes.presentationTime = (long)presentationTime;
|
||||
attributes.number = number;
|
||||
attributes.duration = duration;
|
||||
attributes.sourceDuration = sourceDuration;
|
||||
attributes.indexRange = indexRange;
|
||||
attributes.type = type;
|
||||
|
||||
var segment = SegmentsFromBase(attributes, new List<dynamic>())[0];
|
||||
|
||||
if (initSegment != null){
|
||||
segment.map = initSegment;
|
||||
}
|
||||
|
||||
segments.Add(segment);
|
||||
startIndex += new BigInteger(size);
|
||||
presentationTime += new BigInteger(duration) / new BigInteger(timescale);
|
||||
number++;
|
||||
}
|
||||
|
||||
playlist.segments = segments;
|
||||
|
||||
return playlist;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace CRD.Utils.Parser.Segments;
|
||||
|
||||
public class SegmentList{
|
||||
public static List<dynamic> SegmentsFromList(dynamic attributes, List<dynamic> segmentTimeline){
|
||||
if ((!attributes.duration && segmentTimeline == null) ||
|
||||
(attributes.duration && segmentTimeline != null)){
|
||||
throw new Exception("Segment time unspecified");
|
||||
}
|
||||
|
||||
List<dynamic> segmentUrls = ((List<dynamic>)attributes.segmentUrls)?.ToList() ?? new List<dynamic>();
|
||||
var segmentUrlMap = segmentUrls.Select(segmentUrlObject => SegmentURLToSegmentObject(attributes, segmentUrlObject)).ToList();
|
||||
|
||||
List<dynamic> segmentTimeInfo = null;
|
||||
if (attributes.duration != null){
|
||||
segmentTimeInfo = DurationTimeParser.ParseByDuration(attributes); // Needs to be implemented
|
||||
} else if (segmentTimeline != null){
|
||||
segmentTimeInfo = TimelineTimeParser.ParseByTimeline(attributes, segmentTimeline); // Needs to be implemented
|
||||
}
|
||||
|
||||
var segments = segmentTimeInfo.Select((segmentTime, index) => {
|
||||
if (index < segmentUrlMap.Count){
|
||||
var segment = segmentUrlMap[index];
|
||||
segment.Timeline = segmentTime.Timeline;
|
||||
segment.Duration = segmentTime.Duration;
|
||||
segment.Number = segmentTime.Number;
|
||||
segment.PresentationTime = attributes.periodStart + ((segmentTime.Time - (attributes.presentationTimeOffset ?? 0)) / (attributes.timescale ?? 1));
|
||||
|
||||
return segment;
|
||||
}
|
||||
|
||||
return null;
|
||||
}).Where(segment => segment != null).ToList();
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
public static dynamic SegmentURLToSegmentObject(dynamic attributes, dynamic segmentUrl){
|
||||
var initSegment = UrlType.UrlTypeToSegment(new{
|
||||
baseUrl = attributes.baseUrl,
|
||||
source = attributes.initialization?.sourceURL,
|
||||
range = attributes.initialization?.range
|
||||
});
|
||||
|
||||
var segment = UrlType.UrlTypeToSegment(new{
|
||||
baseUrl = attributes.baseUrl,
|
||||
source = segmentUrl.media,
|
||||
range = segmentUrl.mediaRange
|
||||
});
|
||||
|
||||
segment.Map = initSegment;
|
||||
return segment;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,107 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
|
||||
namespace CRD.Utils.Parser.Segments;
|
||||
|
||||
public class SegmentTemplate{
|
||||
public static List<dynamic> SegmentsFromTemplate(dynamic attributes, List<dynamic> segmentTimeline){
|
||||
dynamic templateValues = new ExpandoObject();
|
||||
templateValues.RepresentationID = ObjectUtilities.GetMemberValue(attributes,"id");
|
||||
templateValues.Bandwidth = ObjectUtilities.GetMemberValue(attributes,"bandwidth") ?? 0;
|
||||
|
||||
dynamic initialization = attributes.initialization ?? new{ sourceURL = string.Empty, range = string.Empty };
|
||||
|
||||
dynamic mapSegment = UrlType.UrlTypeToSegment(new{
|
||||
baseUrl = ObjectUtilities.GetMemberValue(attributes,"baseUrl"),
|
||||
source = ConstructTemplateUrl(initialization.sourceURL, templateValues),
|
||||
range = ObjectUtilities.GetMemberValue(initialization,"range")
|
||||
});
|
||||
|
||||
List<dynamic> segments = ParseTemplateInfo(attributes, segmentTimeline);
|
||||
|
||||
return segments.Select(segment => {
|
||||
templateValues.Number = ObjectUtilities.GetMemberValue(segment,"number");
|
||||
templateValues.Time = ObjectUtilities.GetMemberValue(segment,"time");
|
||||
|
||||
var uri = ConstructTemplateUrl(ObjectUtilities.GetMemberValue(attributes,"media") ?? "", templateValues);
|
||||
var timescale = ObjectUtilities.GetMemberValue(attributes,"timescale") ?? 1;
|
||||
var presentationTimeOffset = ObjectUtilities.GetMemberValue(attributes,"presentationTimeOffset") ?? 0;
|
||||
double presentationTime = ObjectUtilities.GetMemberValue(attributes,"periodStart") + ((ObjectUtilities.GetMemberValue(segment,"time") - presentationTimeOffset) / (double) timescale);
|
||||
|
||||
dynamic map = new ExpandoObject();
|
||||
map.uri = uri;
|
||||
map.timeline = ObjectUtilities.GetMemberValue(segment,"timeline");
|
||||
map.duration = ObjectUtilities.GetMemberValue(segment,"duration");
|
||||
map.resolvedUri = UrlUtils.ResolveUrl(ObjectUtilities.GetMemberValue(attributes,"baseUrl") ?? "", uri);
|
||||
map.map = mapSegment;
|
||||
map.number = ObjectUtilities.GetMemberValue(segment,"number");
|
||||
map.presentationTime = presentationTime;
|
||||
|
||||
return map;
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
|
||||
private static readonly Regex IdentifierPattern = new Regex(@"\$([A-Za-z]*)(?:(%0)([0-9]+)d)?\$", RegexOptions.Compiled);
|
||||
|
||||
public static string ConstructTemplateUrl(string url, dynamic values){
|
||||
// Convert dynamic to IDictionary<string, object> for easier handling
|
||||
var valuesDictionary = (IDictionary<string, object>)values;
|
||||
return IdentifierPattern.Replace(url, match => IdentifierReplacement(match, valuesDictionary));
|
||||
}
|
||||
|
||||
private static string IdentifierReplacement(Match match, IDictionary<string, object> values){
|
||||
if (match.Value == "$$"){
|
||||
// escape sequence
|
||||
return "$";
|
||||
}
|
||||
|
||||
var identifier = match.Groups[1].Value;
|
||||
var format = match.Groups[2].Value;
|
||||
var widthStr = match.Groups[3].Value;
|
||||
|
||||
if (!values.ContainsKey(identifier)){
|
||||
return match.Value;
|
||||
}
|
||||
|
||||
var value = values[identifier]?.ToString() ?? "";
|
||||
|
||||
if (identifier == "RepresentationID"){
|
||||
// Format tag shall not be present with RepresentationID
|
||||
return value;
|
||||
}
|
||||
|
||||
int width = string.IsNullOrEmpty(format) ? 1 : int.Parse(widthStr);
|
||||
if (value.Length >= width){
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.PadLeft(width, '0');
|
||||
}
|
||||
|
||||
public static List<dynamic> ParseTemplateInfo(dynamic attributes, List<dynamic> segmentTimeline){
|
||||
// Check if duration and SegmentTimeline are not present
|
||||
if (ObjectUtilities.GetMemberValue(attributes,"duration") == null && segmentTimeline == null){
|
||||
// Exactly one media segment expected
|
||||
return new List<dynamic>{
|
||||
new{
|
||||
number = ObjectUtilities.GetMemberValue(attributes,"startNumber") ?? 1,
|
||||
duration = ObjectUtilities.GetMemberValue(attributes,"sourceDuration"),
|
||||
time = 0,
|
||||
timeline = ObjectUtilities.GetMemberValue(attributes,"periodStart")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (ObjectUtilities.GetMemberValue(attributes,"duration") != null){
|
||||
// Parse segments based on duration
|
||||
return DurationTimeParser.ParseByDuration(attributes);
|
||||
}
|
||||
|
||||
// Parse segments based on SegmentTimeline
|
||||
return TimelineTimeParser.ParseByTimeline(attributes, segmentTimeline);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
|
||||
namespace CRD.Utils.Parser.Segments;
|
||||
|
||||
public class TimelineTimeParser{
|
||||
public static int GetLiveRValue(dynamic attributes, long time, long duration){
|
||||
long now = (attributes.NOW + attributes.clientOffset) / 1000;
|
||||
long periodStartWC = attributes.availabilityStartTime + (attributes.periodStart ?? 0);
|
||||
long periodEndWC = now + (attributes.minimumUpdatePeriod ?? 0);
|
||||
long periodDuration = periodEndWC - periodStartWC;
|
||||
long timescale = attributes.timescale ?? 1;
|
||||
|
||||
return (int)Math.Ceiling(((periodDuration * timescale) - time) / (double)duration);
|
||||
}
|
||||
|
||||
public static List<dynamic> ParseByTimeline(dynamic attributes, IEnumerable<dynamic> segmentTimeline){
|
||||
var segments = new List<dynamic>();
|
||||
long time = -1;
|
||||
long timescale = attributes.timescale ?? 1;
|
||||
int startNumber = attributes.startNumber ?? 1;
|
||||
double timeline = attributes.periodStart;
|
||||
|
||||
int sIndex = 0;
|
||||
foreach (var S in segmentTimeline){
|
||||
long duration = ObjectUtilities.GetMemberValue(S,"d");
|
||||
int repeat = ObjectUtilities.GetMemberValue(S,"r") ?? 0;
|
||||
long segmentTime = ObjectUtilities.GetMemberValue(S,"t") ?? 0;
|
||||
|
||||
if (time < 0){
|
||||
// first segment
|
||||
time = segmentTime;
|
||||
}
|
||||
|
||||
if (segmentTime > time){
|
||||
// discontinuity
|
||||
time = segmentTime;
|
||||
}
|
||||
|
||||
int count;
|
||||
if (repeat < 0){
|
||||
count = GetLiveRValue(attributes, time, duration);
|
||||
} else{
|
||||
count = repeat + 1;
|
||||
}
|
||||
|
||||
int end = startNumber + segments.Count + count;
|
||||
|
||||
for (int number = startNumber + segments.Count; number < end; number++){
|
||||
segments.Add(new {
|
||||
number = number,
|
||||
duration = duration / (double)timescale,
|
||||
time = time,
|
||||
timeline = timeline
|
||||
});
|
||||
time += duration;
|
||||
}
|
||||
|
||||
sIndex++;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
using System;
|
||||
using CRD.Utils.Parser.Utils;
|
||||
|
||||
namespace CRD.Utils.Parser.Segments;
|
||||
|
||||
public class UrlType{
|
||||
public static dynamic UrlTypeToSegment(dynamic input){
|
||||
dynamic segment = new {
|
||||
uri = ObjectUtilities.GetMemberValue(input,"source"),
|
||||
resolvedUri = new Uri(new Uri(input.baseUrl, UriKind.Absolute), input.source).ToString()
|
||||
};
|
||||
|
||||
string rangeStr = !string.IsNullOrEmpty(input.range) ? ObjectUtilities.GetMemberValue(input,"range") : ObjectUtilities.GetMemberValue(input,"indexRange");
|
||||
if (!string.IsNullOrEmpty(rangeStr)){
|
||||
var ranges = rangeStr.Split('-');
|
||||
long startRange = long.Parse(ranges[0]);
|
||||
long endRange = long.Parse(ranges[1]);
|
||||
long length = endRange - startRange + 1;
|
||||
|
||||
segment.ByteRange = new {
|
||||
length = length,
|
||||
offset = startRange
|
||||
};
|
||||
}
|
||||
|
||||
return segment;
|
||||
}
|
||||
|
||||
|
||||
public static string ByteRangeToString(dynamic byteRange){
|
||||
long endRange = byteRange.offset + byteRange.length - 1;
|
||||
return $"{byteRange.offset}-{endRange}";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
namespace CRD.Utils.Parser.Utils;
|
||||
|
||||
public class DivisionValueParser{
|
||||
public static double ParseDivisionValue(string value){
|
||||
string[] parts = value.Split('/');
|
||||
double result = double.Parse(parts[0]);
|
||||
for (int i = 1; i < parts.Length; i++){
|
||||
result /= double.Parse(parts[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CRD.Utils.Parser.Utils;
|
||||
|
||||
public class DurationParser{
|
||||
private const int SECONDS_IN_YEAR = 365 * 24 * 60 * 60;
|
||||
private const int SECONDS_IN_MONTH = 30 * 24 * 60 * 60;
|
||||
private const int SECONDS_IN_DAY = 24 * 60 * 60;
|
||||
private const int SECONDS_IN_HOUR = 60 * 60;
|
||||
private const int SECONDS_IN_MIN = 60;
|
||||
|
||||
public static double ParseDuration(string str){
|
||||
// P10Y10M10DT10H10M10.1S
|
||||
Regex durationRegex = new Regex(@"P(?:(\d*)Y)?(?:(\d*)M)?(?:(\d*)D)?(?:T(?:(\d*)H)?(?:(\d*)M)?(?:([\d.]*)S)?)?");
|
||||
Match match = durationRegex.Match(str);
|
||||
|
||||
if (!match.Success){
|
||||
return 0;
|
||||
}
|
||||
|
||||
double year = string.IsNullOrEmpty(match.Groups[1].Value) ? 0 : GetDouble(match.Groups[1].Value,0);
|
||||
double month = string.IsNullOrEmpty(match.Groups[2].Value) ? 0 : GetDouble(match.Groups[2].Value,0);
|
||||
double day = string.IsNullOrEmpty(match.Groups[3].Value) ? 0 : GetDouble(match.Groups[3].Value,0);
|
||||
double hour = string.IsNullOrEmpty(match.Groups[4].Value) ? 0 : GetDouble(match.Groups[4].Value,0);
|
||||
double minute = string.IsNullOrEmpty(match.Groups[5].Value) ? 0 : GetDouble(match.Groups[5].Value,0);
|
||||
double second = string.IsNullOrEmpty(match.Groups[6].Value) ? 0 : GetDouble(match.Groups[6].Value,0);
|
||||
|
||||
return (year * SECONDS_IN_YEAR +
|
||||
month * SECONDS_IN_MONTH +
|
||||
day * SECONDS_IN_DAY +
|
||||
hour * SECONDS_IN_HOUR +
|
||||
minute * SECONDS_IN_MIN +
|
||||
second);
|
||||
}
|
||||
|
||||
public static double GetDouble(string value, double defaultValue){
|
||||
double result;
|
||||
|
||||
// // Try parsing in the current culture
|
||||
// if (!double.TryParse(value, System.Globalization.NumberStyles.Any, CultureInfo.CurrentCulture, out result) &&
|
||||
// // Then try in US english
|
||||
// !double.TryParse(value, System.Globalization.NumberStyles.Any, CultureInfo.GetCultureInfo("en-US"), out result) &&
|
||||
// // Then in neutral language
|
||||
// !double.TryParse(value, System.Globalization.NumberStyles.Any, CultureInfo.InvariantCulture, out result))
|
||||
// {
|
||||
// result = defaultValue;
|
||||
// }
|
||||
return double.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public static long ParseDate(string str){
|
||||
// Date format without timezone according to ISO 8601
|
||||
// YYY-MM-DDThh:mm:ss.ssssss
|
||||
string dateRegexPattern = @"^\d+-\d+-\d+T\d+:\d+:\d+(\.\d+)?$";
|
||||
|
||||
// If the date string does not specify a timezone, we must specify UTC. This is
|
||||
// expressed by ending with 'Z'
|
||||
if (Regex.IsMatch(str, dateRegexPattern)){
|
||||
str += 'Z';
|
||||
}
|
||||
|
||||
return DateTimeOffset.Parse(str).ToUnixTimeMilliseconds();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace CRD.Utils.Parser.Utils;
|
||||
|
||||
public class ManifestInfo{
|
||||
public dynamic locations{ get; set; }
|
||||
public dynamic contentSteeringInfo{ get; set; }
|
||||
public dynamic representationInfo{ get; set; }
|
||||
public dynamic eventStream{ get; set; }
|
||||
}
|
|
@ -0,0 +1,110 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Dynamic;
|
||||
using System.Linq;
|
||||
|
||||
namespace CRD.Utils.Parser.Utils;
|
||||
|
||||
public class ObjectUtilities{
|
||||
public static ExpandoObject MergeExpandoObjects(dynamic target, dynamic source){
|
||||
var result = new ExpandoObject();
|
||||
var resultDict = result as IDictionary<string, object>;
|
||||
|
||||
// Cast source and target to dictionaries if they are not null
|
||||
var targetDict = target as IDictionary<string, object>;
|
||||
var sourceDict = source as IDictionary<string, object>;
|
||||
|
||||
// If both are null, return an empty ExpandoObject
|
||||
if (targetDict == null && sourceDict == null){
|
||||
Console.WriteLine("Nothing Merged; both are empty");
|
||||
return result; // result is already a new ExpandoObject
|
||||
}
|
||||
|
||||
// Copy targetDict into resultDict
|
||||
if (targetDict != null){
|
||||
foreach (var kvp in targetDict){
|
||||
resultDict[kvp.Key] = kvp.Value; // Add or overwrite key-value pairs
|
||||
}
|
||||
}
|
||||
|
||||
// Copy sourceDict into resultDict, potentially overwriting values from targetDict
|
||||
if (sourceDict != null){
|
||||
foreach (var kvp in sourceDict){
|
||||
resultDict[kvp.Key] = kvp.Value; // Overwrites if key exists
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void SetAttributeWithDefault(dynamic ob, string attributeName, string defaultValue){
|
||||
var obDict = ob as IDictionary<string, object>;
|
||||
|
||||
if (obDict == null){
|
||||
throw new ArgumentException("Provided object must be an ExpandoObject.");
|
||||
}
|
||||
|
||||
// Check if the attribute exists and is not null or empty
|
||||
if (obDict.TryGetValue(attributeName, out object value) && value != null && !string.IsNullOrEmpty(value.ToString())){
|
||||
obDict[attributeName] = value;
|
||||
} else{
|
||||
obDict[attributeName] = defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
public static object GetAttributeWithDefault(dynamic ob, string attributeName, string defaultValue){
|
||||
var obDict = ob as IDictionary<string, object>;
|
||||
|
||||
if (obDict == null){
|
||||
throw new ArgumentException("Provided object must be an ExpandoObject.");
|
||||
}
|
||||
|
||||
// Check if the attribute exists and is not null or empty
|
||||
if (obDict.TryGetValue(attributeName, out object value) && value != null && !string.IsNullOrEmpty(value.ToString())){
|
||||
return value;
|
||||
} else{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetFieldFromOrToDefault(dynamic targetObject, string fieldToSet, string fieldToGetValueFrom, object defaultValue){
|
||||
var targetDict = targetObject as IDictionary<string, object>;
|
||||
|
||||
if (targetDict == null){
|
||||
throw new ArgumentException("Provided targetObject must be an ExpandoObject.");
|
||||
}
|
||||
|
||||
// Attempt to get the value from the specified field
|
||||
object valueToSet = defaultValue;
|
||||
if (targetDict.TryGetValue(fieldToGetValueFrom, out object valueFromField) && valueFromField != null){
|
||||
valueToSet = valueFromField;
|
||||
}
|
||||
|
||||
// Set the specified field to the retrieved value or the default value
|
||||
targetDict[fieldToSet] = valueToSet;
|
||||
}
|
||||
|
||||
public static object GetMemberValue(dynamic obj, string memberName){
|
||||
// First, check if the object is indeed an ExpandoObject
|
||||
if (obj is ExpandoObject expando){
|
||||
// Try to get the value from the ExpandoObject
|
||||
var dictionary = (IDictionary<string, object>)expando;
|
||||
if (dictionary.TryGetValue(memberName, out object value)){
|
||||
// Return the found value, which could be null
|
||||
return value;
|
||||
}
|
||||
} else if (obj != null){
|
||||
// For non-ExpandoObject dynamics, attempt to access the member directly
|
||||
// This part might throw exceptions if the member does not exist
|
||||
try{
|
||||
return obj.GetType().GetProperty(memberName)?.GetValue(obj, null) ??
|
||||
obj.GetType().GetField(memberName)?.GetValue(obj);
|
||||
} catch{
|
||||
// Member access failed, handle accordingly (e.g., log the issue)
|
||||
}
|
||||
}
|
||||
|
||||
// Member doesn't exist or obj is null, return null or a default value
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using System;
|
||||
|
||||
namespace CRD.Utils.Parser.Utils;
|
||||
|
||||
public class UrlResolver{
|
||||
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
using System;
|
||||
|
||||
namespace CRD.Utils.Parser.Utils;
|
||||
|
||||
public class UrlUtils{
|
||||
public static string ResolveUrl(string baseUrl, string relativeUrl){
|
||||
// Return early if the relative URL is actually an absolute URL
|
||||
if (Uri.IsWellFormedUriString(relativeUrl, UriKind.Absolute))
|
||||
return relativeUrl;
|
||||
|
||||
// Handle the case where baseUrl is not specified or invalid
|
||||
Uri baseUri;
|
||||
if (string.IsNullOrEmpty(baseUrl) || !Uri.TryCreate(baseUrl, UriKind.Absolute, out baseUri)){
|
||||
// Assuming you want to use a default base if none is provided
|
||||
// For example, you could default to "http://example.com"
|
||||
// This part is up to how you want to handle such cases
|
||||
baseUri = new Uri("http://example.com");
|
||||
}
|
||||
|
||||
Uri resolvedUri = new Uri(baseUri, relativeUrl);
|
||||
return resolvedUri.ToString();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Xml;
|
||||
|
||||
namespace CRD.Utils.Parser.Utils;
|
||||
|
||||
public class XMLUtils{
|
||||
public static List<XmlElement> FindChildren(XmlElement element, string name){
|
||||
return From(element.ChildNodes).OfType<XmlElement>().Where(child => child.Name == name).ToList();
|
||||
}
|
||||
|
||||
public static string GetContent(XmlElement element){
|
||||
return element.InnerText.Trim();
|
||||
}
|
||||
|
||||
private static List<XmlNode> From(XmlNodeList list){
|
||||
if (list.Count == 0){
|
||||
return new List<XmlNode>();
|
||||
}
|
||||
|
||||
List<XmlNode> result = new List<XmlNode>(list.Count);
|
||||
|
||||
for (int i = 0; i < list.Count; i++){
|
||||
result.Add(list[i]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Downloader;
|
||||
using CRD.Views;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class CalendarWeek{
|
||||
public DateTime? FirstDayOfWeek{ get; set; }
|
||||
public string? FirstDayOfWeekString{ get; set; }
|
||||
public List<CalendarDay>? CalendarDays{ get; set; }
|
||||
}
|
||||
|
||||
public class CalendarDay{
|
||||
public DateTime? DateTime{ get; set; }
|
||||
public string? DayName{ get; set; }
|
||||
public List<CalendarEpisode>? CalendarEpisodes{ get; set; }
|
||||
}
|
||||
|
||||
public partial class CalendarEpisode : INotifyPropertyChanged{
|
||||
public DateTime? DateTime{ get; set; }
|
||||
public bool? HasPassed{ get; set; }
|
||||
public string? EpisodeName{ get; set; }
|
||||
public string? SeasonUrl{ get; set; }
|
||||
public string? EpisodeUrl{ get; set; }
|
||||
public string? ThumbnailUrl{ get; set; }
|
||||
public Bitmap? ImageBitmap{ get; set; }
|
||||
|
||||
public string? EpisodeNumber{ get; set; }
|
||||
|
||||
public bool IsPremiumOnly{ get; set; }
|
||||
|
||||
public string? SeasonName{ get; set; }
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
[RelayCommand]
|
||||
public void AddEpisodeToQue(string episodeUrl){
|
||||
var match = Regex.Match(episodeUrl, "/([^/]+)/watch/([^/]+)");
|
||||
|
||||
if (match.Success){
|
||||
var locale = match.Groups[1].Value; // Capture the locale part
|
||||
var id = match.Groups[2].Value; // Capture the ID part
|
||||
Crunchyroll.Instance.AddEpisodeToQue(id, locale, Crunchyroll.Instance.CrunOptions.DubLang);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoadImage(){
|
||||
try{
|
||||
using (var client = new HttpClient()){
|
||||
var response = await client.GetAsync(ThumbnailUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using (var stream = await response.Content.ReadAsStreamAsync()){
|
||||
ImageBitmap = new Bitmap(stream);
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ImageBitmap)));
|
||||
}
|
||||
}
|
||||
} catch (Exception ex){
|
||||
// Handle exceptions
|
||||
Console.WriteLine("Failed to load image: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public struct CrunchyChapters{
|
||||
public List<CrunchyChapter> Chapters { get; set; }
|
||||
public DateTime? lastUpdate { get; set; }
|
||||
public string? mediaId { get; set; }
|
||||
}
|
||||
|
||||
public struct CrunchyChapter{
|
||||
public string approverId { get; set; }
|
||||
public string distributionNumber { get; set; }
|
||||
public int? end { get; set; }
|
||||
public int? start { get; set; }
|
||||
public string title { get; set; }
|
||||
public string seriesId { get; set; }
|
||||
[JsonProperty("new")]
|
||||
public bool New { get; set; }
|
||||
public string type { get; set; }
|
||||
}
|
||||
|
||||
public struct CrunchyOldChapter{
|
||||
public string media_id { get; set; }
|
||||
public double startTime { get; set; }
|
||||
public double endTime { get; set; }
|
||||
public double duration { get; set; }
|
||||
public string comparedWith { get; set; }
|
||||
public string ordering { get; set; }
|
||||
public DateTime last_updated { get; set; }
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class CrCmsToken{
|
||||
[JsonProperty("cms")] public CmsTokenB Cms{ get; set; }
|
||||
[JsonProperty("cms_beta")] public CmsTokenB CmsBeta{ get; set; }
|
||||
[JsonProperty("cms_web")] public CmsTokenB CmsWeb{ get; set; }
|
||||
|
||||
[JsonProperty("service_available")] public bool ServiceAvailable{ get; set; }
|
||||
|
||||
[JsonProperty("default_marketing_opt_in")]
|
||||
public bool DefaultMarketingOptIn{ get; set; }
|
||||
}
|
||||
|
||||
public struct CmsTokenB{
|
||||
public string Bucket{ get; set; }
|
||||
public string Policy{ get; set; }
|
||||
public string Signature{ get; set; }
|
||||
[JsonProperty("key_pair_id")] public string KeyPairId{ get; set; }
|
||||
|
||||
public DateTime Expires{ get; set; }
|
||||
}
|
|
@ -0,0 +1,123 @@
|
|||
using System.Collections.Generic;
|
||||
using YamlDotNet.Serialization;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class CrDownloadOptions{
|
||||
[YamlMember(Alias = "hard_sub_lang", ApplyNamingConventions = false)]
|
||||
public string Hslang{ get; set; } //locale string none or locale
|
||||
|
||||
[YamlIgnore]
|
||||
public int Kstream{ get; set; }
|
||||
|
||||
[YamlMember(Alias = "no_video", ApplyNamingConventions = false)]
|
||||
public bool Novids{ get; set; } //dont download videos
|
||||
|
||||
[YamlMember(Alias = "no_audio", ApplyNamingConventions = false)]
|
||||
public bool Noaudio{ get; set; } //dont download audio
|
||||
|
||||
[YamlIgnore]
|
||||
public int X{ get; set; } // selected server
|
||||
|
||||
[YamlMember(Alias = "quality_video", ApplyNamingConventions = false)]
|
||||
public string QualityVideo{ get; set; } //quality 0 is best
|
||||
|
||||
[YamlMember(Alias = "quality_audio", ApplyNamingConventions = false)]
|
||||
public string QualityAudio{ get; set; } //quality 0 is best
|
||||
|
||||
[YamlMember(Alias = "file_name", ApplyNamingConventions = false)]
|
||||
public string FileName{ get; set; } //
|
||||
|
||||
[YamlMember(Alias = "leading_numbers", ApplyNamingConventions = false)]
|
||||
public int Numbers{ get; set; } //leading 0 probably
|
||||
|
||||
[YamlIgnore]
|
||||
public int Partsize{ get; set; } // download parts at same time?
|
||||
|
||||
[YamlIgnore]
|
||||
public int Timeout{ get; set; }
|
||||
|
||||
[YamlIgnore]
|
||||
public int Waittime{ get; set; }
|
||||
|
||||
[YamlIgnore]
|
||||
public int FsRetryTime{ get; set; }
|
||||
|
||||
[YamlMember(Alias = "soft_subs", ApplyNamingConventions = false)]
|
||||
public List<string> DlSubs{ get; set; } //all or local for subs to download
|
||||
|
||||
[YamlIgnore]
|
||||
public bool SkipSubs{ get; set; } // don't download subs
|
||||
|
||||
[YamlIgnore]
|
||||
public bool NoSubs{ get; set; } // don't download subs
|
||||
|
||||
[YamlMember(Alias = "mux_mp4", ApplyNamingConventions = false)]
|
||||
public bool Mp4{ get; set; } // mp4 output else mkv
|
||||
|
||||
[YamlIgnore]
|
||||
public List<string> Override{ get; set; }
|
||||
|
||||
[YamlIgnore]
|
||||
public string VideoTitle{ get; set; } // ???
|
||||
|
||||
[YamlIgnore]
|
||||
public string Force{ get; set; } // always Y
|
||||
|
||||
[YamlMember(Alias = "mux_ffmpeg", ApplyNamingConventions = false)]
|
||||
public List<string> FfmpegOptions{ get; set; } //additional ffmpeg options
|
||||
|
||||
[YamlMember(Alias = "mux_mkvmerge", ApplyNamingConventions = false)]
|
||||
public List<string> MkvmergeOptions{ get; set; } //additional mkvmerge
|
||||
|
||||
[YamlIgnore]
|
||||
public LanguageItem DefaultSub{ get; set; } //default sub
|
||||
|
||||
[YamlIgnore]
|
||||
public LanguageItem DefaultAudio{ get; set; } //default audio
|
||||
|
||||
[YamlIgnore]
|
||||
public string CcTag{ get; set; } //cc tag ??
|
||||
|
||||
[YamlIgnore]
|
||||
public bool DlVideoOnce{ get; set; } // don't download same video multiple times
|
||||
|
||||
[YamlIgnore]
|
||||
public bool? Skipmux{ get; set; } //mux in the end or not
|
||||
|
||||
[YamlIgnore]
|
||||
public bool SyncTiming{ get; set; } // sync timing in muxing
|
||||
|
||||
[YamlIgnore]
|
||||
public bool Nocleanup{ get; set; } // cleanup files after muxing
|
||||
|
||||
[YamlMember(Alias = "chapters", ApplyNamingConventions = false)]
|
||||
public bool Chapters{ get; set; } // download chaperts
|
||||
|
||||
[YamlIgnore]
|
||||
public string? FontName{ get; set; } //font sutff
|
||||
|
||||
[YamlIgnore]
|
||||
public bool OriginalFontSize{ get; set; } //font sutff
|
||||
|
||||
[YamlIgnore]
|
||||
public int FontSize{ get; set; } //font sutff
|
||||
|
||||
[YamlMember(Alias = "dub_lang", ApplyNamingConventions = false)]
|
||||
public List<string> DubLang{ get; set; } //dub lang download
|
||||
|
||||
[YamlMember(Alias = "simultaneous_downloads", ApplyNamingConventions = false)]
|
||||
public int SimultaneousDownloads{ get; set; }
|
||||
|
||||
[YamlMember(Alias = "theme", ApplyNamingConventions = false)]
|
||||
public string Theme{ get; set; }
|
||||
|
||||
[YamlMember(Alias = "accent_color", ApplyNamingConventions = false)]
|
||||
public string? AccentColor{ get; set; }
|
||||
|
||||
[YamlIgnore]
|
||||
public string? SelectedCalendarLanguage{ get; set; }
|
||||
|
||||
[YamlMember(Alias = "history", ApplyNamingConventions = false)]
|
||||
public bool History{ get; set; }
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class CrProfile{
|
||||
public string? Avatar{ get; set; }
|
||||
public string? Email{ get; set; }
|
||||
public string? Username{ get; set; }
|
||||
|
||||
[JsonProperty("preferred_content_audio_language")]
|
||||
public string? PreferredContentAudioLanguage{ get; set; }
|
||||
|
||||
[JsonProperty("preferred_content_subtitle_language")]
|
||||
public string? PreferredContentSubtitleLanguage{ get; set; }
|
||||
}
|
|
@ -0,0 +1,96 @@
|
|||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class CrSeriesBase{
|
||||
public int Total{ get; set; }
|
||||
public SeriesBaseItem[]? Data{ get; set; }
|
||||
public Meta Meta{ get; set; }
|
||||
}
|
||||
|
||||
public struct SeriesBaseItem{
|
||||
[JsonProperty("extended_maturity_rating")]
|
||||
public Dictionary<object, object>
|
||||
ExtendedMaturityRating{ get; set; }
|
||||
|
||||
[JsonProperty("extended_description")]
|
||||
public string ExtendedDescription{ get; set; }
|
||||
|
||||
[JsonProperty("episode_count")]
|
||||
public int EpisodeCount{ get; set; }
|
||||
|
||||
[JsonProperty("is_mature")]
|
||||
public bool IsMature{ get; set; }
|
||||
|
||||
|
||||
public Images Images{ get; set; }
|
||||
|
||||
|
||||
[JsonProperty("season_count")]
|
||||
public int SeasonCount{ get; set; }
|
||||
|
||||
[JsonProperty("content_descriptors")]
|
||||
public List<string> ContentDescriptors{ get; set; }
|
||||
|
||||
|
||||
public string Id{ get; set; }
|
||||
|
||||
|
||||
[JsonProperty("media_count")]
|
||||
public int MediaCount{ get; set; }
|
||||
|
||||
|
||||
[JsonProperty("is_simulcast")]
|
||||
public bool IsSimulcast{ get; set; }
|
||||
|
||||
[JsonProperty("seo_description")]
|
||||
public string SeoDescription{ get; set; }
|
||||
|
||||
[JsonProperty("availability_notes")]
|
||||
public string AvailabilityNotes{ get; set; }
|
||||
|
||||
[JsonProperty("season_tags")]
|
||||
public List<string> SeasonTags{ get; set; }
|
||||
|
||||
[JsonProperty("maturity_ratings")]
|
||||
public List<string> MaturityRatings{ get; set; }
|
||||
|
||||
[JsonProperty("mature_blocked")]
|
||||
public bool MatureBlocked{ get; set; }
|
||||
|
||||
[JsonProperty("is_dubbed")]
|
||||
public bool IsDubbed{ get; set; }
|
||||
|
||||
[JsonProperty("series_launch_year")]
|
||||
public int SeriesLaunchYear{ get; set; }
|
||||
|
||||
public string Slug{ get; set; }
|
||||
|
||||
[JsonProperty("content_provider")]
|
||||
public string ContentProvider{ get; set; }
|
||||
|
||||
[JsonProperty("subtitle_locales")]
|
||||
public List<string> SubtitleLocales{ get; set; }
|
||||
|
||||
public string Title{ get; set; }
|
||||
|
||||
[JsonProperty("is_subbed")]
|
||||
public bool IsSubbed{ get; set; }
|
||||
|
||||
[JsonProperty("seo_title")]
|
||||
public string SeoTitle{ get; set; }
|
||||
|
||||
[JsonProperty("channel_id")]
|
||||
public string ChannelId{ get; set; }
|
||||
|
||||
[JsonProperty("slug_title")]
|
||||
public string SlugTitle{ get; set; }
|
||||
|
||||
public string Description{ get; set; }
|
||||
|
||||
public List<string> Keywords{ get; set; }
|
||||
|
||||
[JsonProperty("audio_locales")]
|
||||
public List<string> AudioLocales{ get; set; }
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class CrSeriesSearch{
|
||||
public int Total{ get; set; }
|
||||
public SeriesSearchItem[]? Data{ get; set; }
|
||||
public Meta Meta{ get; set; }
|
||||
}
|
||||
|
||||
public struct SeriesSearchItem{
|
||||
public string Description{ get; set; }
|
||||
[JsonProperty("seo_description")] public string SeoDescription{ get; set; }
|
||||
[JsonProperty("number_of_episodes")] public int NumberOfEpisodes{ get; set; }
|
||||
[JsonProperty("is_dubbed")] public bool IsDubbed{ get; set; }
|
||||
public string Identifier{ get; set; }
|
||||
[JsonProperty("channel_id")] public string ChannelId{ get; set; }
|
||||
[JsonProperty("slug_title")] public string SlugTitle{ get; set; }
|
||||
|
||||
[JsonProperty("season_sequence_number")]
|
||||
public int SeasonSequenceNumber{ get; set; }
|
||||
|
||||
[JsonProperty("season_tags")] public List<string> SeasonTags{ get; set; }
|
||||
|
||||
[JsonProperty("extended_maturity_rating")]
|
||||
public Dictionary<object, object>
|
||||
ExtendedMaturityRating{ get; set; }
|
||||
|
||||
[JsonProperty("is_mature")] public bool IsMature{ get; set; }
|
||||
[JsonProperty("audio_locale")] public string AudioLocale{ get; set; }
|
||||
[JsonProperty("season_number")] public int SeasonNumber{ get; set; }
|
||||
public Dictionary<object, object> Images{ get; set; }
|
||||
[JsonProperty("mature_blocked")] public bool MatureBlocked{ get; set; }
|
||||
public List<Version> Versions{ get; set; }
|
||||
public string Title{ get; set; }
|
||||
[JsonProperty("is_subbed")] public bool IsSubbed{ get; set; }
|
||||
public string Id{ get; set; }
|
||||
[JsonProperty("audio_locales")] public List<string> AudioLocales{ get; set; }
|
||||
[JsonProperty("subtitle_locales")] public List<string> SubtitleLocales{ get; set; }
|
||||
[JsonProperty("availability_notes")] public string AvailabilityNotes{ get; set; }
|
||||
[JsonProperty("series_id")] public string SeriesId{ get; set; }
|
||||
|
||||
[JsonProperty("season_display_number")]
|
||||
public string SeasonDisplayNumber{ get; set; }
|
||||
|
||||
[JsonProperty("is_complete")] public bool IsComplete{ get; set; }
|
||||
public List<string> Keywords{ get; set; }
|
||||
[JsonProperty("maturity_ratings")] public List<string> MaturityRatings{ get; set; }
|
||||
[JsonProperty("is_simulcast")] public bool IsSimulcast{ get; set; }
|
||||
[JsonProperty("seo_title")] public string SeoTitle{ get; set; }
|
||||
}
|
||||
|
||||
public struct Version{
|
||||
[JsonProperty("audio_locale")] public string? AudioLocale{ get; set; }
|
||||
public string? Guid{ get; set; }
|
||||
public bool? Original{ get; set; }
|
||||
public string? Variant{ get; set; }
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
using System;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class CrToken{
|
||||
public string? access_token { get; set; }
|
||||
public string? refresh_token { get; set; }
|
||||
public int? expires_in { get; set; }
|
||||
public string? token_type { get; set; }
|
||||
public string? scope { get; set; }
|
||||
public string? country { get; set; }
|
||||
public string? account_id { get; set; }
|
||||
public string? profile_id { get; set; }
|
||||
public DateTime? expires { get; set; }
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class CrunchyNoDrmStream{
|
||||
public string? AssetId{ get; set; }
|
||||
public string? AudioLocale{ get; set; }
|
||||
public string? Bifs{ get; set; }
|
||||
public string? BurnedInLocale{ get; set; }
|
||||
public Dictionary<string, Caption>? Captions{ get; set; }
|
||||
public Dictionary<string, HardSub>? HardSubs{ get; set; }
|
||||
public string? PlaybackType{ get; set; }
|
||||
public Session? Session{ get; set; }
|
||||
public Dictionary<string, Subtitle>? Subtitles{ get; set; }
|
||||
public string? Token{ get; set; }
|
||||
public string? Url{ get; set; }
|
||||
public List<object>? Versions{ get; set; } // Use a more specific type if known
|
||||
}
|
||||
|
||||
public class Caption{
|
||||
public string? Format{ get; set; }
|
||||
public string? Language{ get; set; }
|
||||
public string? Url{ get; set; }
|
||||
}
|
||||
|
||||
public class HardSub{
|
||||
public string? Hlang{ get; set; }
|
||||
public string? Url{ get; set; }
|
||||
public string? Quality{ get; set; }
|
||||
}
|
||||
|
||||
public class Session{
|
||||
public int? RenewSeconds{ get; set; }
|
||||
public int? NoNetworkRetryIntervalSeconds{ get; set; }
|
||||
public int? NoNetworkTimeoutSeconds{ get; set; }
|
||||
public int? MaximumPauseSeconds{ get; set; }
|
||||
public int? EndOfVideoUnloadSeconds{ get; set; }
|
||||
public int? SessionExpirationSeconds{ get; set; }
|
||||
public bool? UsesStreamLimits{ get; set; }
|
||||
}
|
||||
|
||||
public class Subtitle{
|
||||
public string? Format{ get; set; }
|
||||
public string? Language{ get; set; }
|
||||
public string? Url{ get; set; }
|
||||
}
|
|
@ -0,0 +1,211 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public struct CrunchyEpisodeList{
|
||||
public int Total{ get; set; }
|
||||
public List<CrunchyEpisode>? Data{ get; set; }
|
||||
public Meta Meta{ get; set; }
|
||||
}
|
||||
|
||||
public struct CrunchyEpisode{
|
||||
[JsonProperty("next_episode_id")] public string NextEpisodeId{ get; set; }
|
||||
[JsonProperty("series_id")] public string SeriesId{ get; set; }
|
||||
[JsonProperty("season_number")] public int SeasonNumber{ get; set; }
|
||||
[JsonProperty("next_episode_title")] public string NextEpisodeTitle{ get; set; }
|
||||
[JsonProperty("availability_notes")] public string AvailabilityNotes{ get; set; }
|
||||
[JsonProperty("duration_ms")] public int DurationMs{ get; set; }
|
||||
[JsonProperty("series_slug_title")] public string SeriesSlugTitle{ get; set; }
|
||||
[JsonProperty("series_title")] public string SeriesTitle{ get; set; }
|
||||
[JsonProperty("is_dubbed")] public bool IsDubbed{ get; set; }
|
||||
public List<EpisodeVersion>? Versions{ get; set; } // Assume Version is defined elsewhere.
|
||||
public string Identifier{ get; set; }
|
||||
[JsonProperty("sequence_number")] public float SequenceNumber{ get; set; }
|
||||
[JsonProperty("eligible_region")] public string EligibleRegion{ get; set; }
|
||||
[JsonProperty("availability_starts")] public DateTime? AvailabilityStarts{ get; set; }
|
||||
public Images? Images{ get; set; } // Assume Images is a struct or class you've defined elsewhere.
|
||||
[JsonProperty("season_id")] public string SeasonId{ get; set; }
|
||||
[JsonProperty("seo_title")] public string SeoTitle{ get; set; }
|
||||
[JsonProperty("is_premium_only")] public bool IsPremiumOnly{ get; set; }
|
||||
|
||||
[JsonProperty("extended_maturity_rating")]
|
||||
public Dictionary<string, object> ExtendedMaturityRating{ get; set; }
|
||||
|
||||
public string Title{ get; set; }
|
||||
|
||||
[JsonProperty("production_episode_id")]
|
||||
public string ProductionEpisodeId{ get; set; }
|
||||
|
||||
[JsonProperty("premium_available_date")]
|
||||
public DateTime? PremiumAvailableDate{ get; set; }
|
||||
|
||||
[JsonProperty("season_title")] public string SeasonTitle{ get; set; }
|
||||
[JsonProperty("seo_description")] public string SeoDescription{ get; set; }
|
||||
|
||||
[JsonProperty("audio_locale")] public string AudioLocale{ get; set; }
|
||||
public string Id{ get; set; }
|
||||
[JsonProperty("media_type")] public MediaType? MediaType{ get; set; } // MediaType should be an enum you define based on possible values.
|
||||
[JsonProperty("availability_ends")] public DateTime? AvailabilityEnds{ get; set; }
|
||||
[JsonProperty("free_available_date")] public DateTime? FreeAvailableDate{ get; set; }
|
||||
public string Playback{ get; set; }
|
||||
[JsonProperty("channel_id")] public ChannelId? ChannelId{ get; set; } // ChannelID should be an enum or struct.
|
||||
public string? Episode{ get; set; }
|
||||
[JsonProperty("is_mature")] public bool IsMature{ get; set; }
|
||||
[JsonProperty("listing_id")] public string ListingId{ get; set; }
|
||||
[JsonProperty("episode_air_date")] public DateTime? EpisodeAirDate{ get; set; }
|
||||
public string Slug{ get; set; }
|
||||
[JsonProperty("available_date")] public DateTime? AvailableDate{ get; set; }
|
||||
[JsonProperty("subtitle_locales")] public List<string> SubtitleLocales{ get; set; }
|
||||
[JsonProperty("slug_title")] public string SlugTitle{ get; set; }
|
||||
[JsonProperty("available_offline")] public bool AvailableOffline{ get; set; }
|
||||
public string Description{ get; set; }
|
||||
[JsonProperty("is_subbed")] public bool IsSubbed{ get; set; }
|
||||
[JsonProperty("premium_date")] public DateTime? PremiumDate{ get; set; }
|
||||
[JsonProperty("upload_date")] public DateTime? UploadDate{ get; set; }
|
||||
[JsonProperty("season_slug_title")] public string SeasonSlugTitle{ get; set; }
|
||||
|
||||
[JsonProperty("closed_captions_available")]
|
||||
public bool ClosedCaptionsAvailable{ get; set; }
|
||||
|
||||
[JsonProperty("episode_number")] public int? EpisodeNumber{ get; set; }
|
||||
[JsonProperty("season_tags")] public List<object> SeasonTags{ get; set; } // More specific type could be used if known.
|
||||
[JsonProperty("maturity_ratings")] public List<string> MaturityRatings{ get; set; } // MaturityRating should be defined based on possible values.
|
||||
[JsonProperty("streams_link")] public string? StreamsLink{ get; set; }
|
||||
[JsonProperty("mature_blocked")] public bool? MatureBlocked{ get; set; }
|
||||
[JsonProperty("is_clip")] public bool IsClip{ get; set; }
|
||||
[JsonProperty("hd_flag")] public bool HdFlag{ get; set; }
|
||||
[JsonProperty("hide_season_title")] public bool? HideSeasonTitle{ get; set; }
|
||||
[JsonProperty("hide_season_number")] public bool? HideSeasonNumber{ get; set; }
|
||||
public bool? IsSelected{ get; set; }
|
||||
[JsonProperty("seq_id")] public string SeqId{ get; set; }
|
||||
[JsonProperty("__links__")] public Links? Links{ get; set; }
|
||||
}
|
||||
|
||||
// public struct CrunchyEpisode{
|
||||
//
|
||||
// public string channel_id{ get; set; }
|
||||
// public bool is_mature{ get; set; }
|
||||
// public string upload_date{ get; set; }
|
||||
// public string free_available_date{ get; set; }
|
||||
// public List<string> content_descriptors{ get; set; }
|
||||
// public Dictionary<object, object> images{ get; set; } // Consider specifying actual key and value types if known
|
||||
// public int season_sequence_number{ get; set; }
|
||||
// public string audio_locale{ get; set; }
|
||||
// public string title{ get; set; }
|
||||
// public Dictionary<object, object>
|
||||
// extended_maturity_rating{ get; set; } // Consider specifying actual key and value types if known
|
||||
// public bool available_offline{ get; set; }
|
||||
// public string identifier{ get; set; }
|
||||
// public string listing_id{ get; set; }
|
||||
// public List<string> season_tags{ get; set; }
|
||||
// public string next_episode_id{ get; set; }
|
||||
// public string next_episode_title{ get; set; }
|
||||
// public bool is_subbed{ get; set; }
|
||||
// public string slug{ get; set; }
|
||||
// public List<Version> versions{ get; set; }
|
||||
// public int season_number{ get; set; }
|
||||
// public string availability_ends{ get; set; }
|
||||
// public string eligible_region{ get; set; }
|
||||
// public bool is_clip{ get; set; }
|
||||
// public string description{ get; set; }
|
||||
// public string seo_description{ get; set; }
|
||||
// public bool is_premium_only{ get; set; }
|
||||
// public string streams_link{ get; set; }
|
||||
// public int episode_number{ get; set; }
|
||||
// public bool closed_captions_available{ get; set; }
|
||||
//
|
||||
// public bool is_dubbed{ get; set; }
|
||||
// public string seo_title{ get; set; }
|
||||
// public long duration_ms{ get; set; }
|
||||
// public string id{ get; set; }
|
||||
// public string series_id{ get; set; }
|
||||
// public string series_slug_title{ get; set; }
|
||||
// public string episode_air_date{ get; set; }
|
||||
// public bool hd_flag{ get; set; }
|
||||
// public bool mature_blocked{ get; set; }
|
||||
//
|
||||
// public string availability_notes{ get; set; }
|
||||
//
|
||||
// public List<string> maturity_ratings{ get; set; }
|
||||
// public string episode{ get; set; }
|
||||
// public int sequence_number{ get; set; }
|
||||
// public List<string> subtitle_locales{ get; set; }
|
||||
//
|
||||
// }
|
||||
|
||||
public struct Images{
|
||||
[JsonProperty("poster_tall")] public List<List<Image>>? PosterTall{ get; set; }
|
||||
[JsonProperty("poster_wide")] public List<List<Image>>? PosterWide{ get; set; }
|
||||
[JsonProperty("promo_image")] public List<List<Image>>? PromoImage{ get; set; }
|
||||
public List<List<Image>> Thumbnail{ get; set; }
|
||||
}
|
||||
|
||||
public struct Image{
|
||||
public int Height{ get; set; }
|
||||
public string Source{ get; set; }
|
||||
public ImageType Type{ get; set; }
|
||||
public int Width{ get; set; }
|
||||
}
|
||||
|
||||
public struct EpisodeVersion{
|
||||
[JsonProperty("audio_locale")] public string AudioLocale{ get; set; }
|
||||
public string Guid{ get; set; }
|
||||
[JsonProperty("is_premium_only")] public bool IsPremiumOnly{ get; set; }
|
||||
[JsonProperty("media_guid")] public string? MediaGuid{ get; set; }
|
||||
public bool Original{ get; set; }
|
||||
[JsonProperty("season_guid")] public string SeasonGuid{ get; set; }
|
||||
public string Variant{ get; set; }
|
||||
}
|
||||
|
||||
public struct Link{
|
||||
public string Href{ get; set; }
|
||||
}
|
||||
|
||||
public struct Links(){
|
||||
public Dictionary<string, Link> LinkMappings{ get; set; } = new(){
|
||||
{ "episode/channel", default },
|
||||
{ "episode/next_episode", default },
|
||||
{ "episode/season", default },
|
||||
{ "episode/series", default },
|
||||
{ "streams", default }
|
||||
};
|
||||
}
|
||||
|
||||
public class CrunchyEpMeta{
|
||||
public List<CrunchyEpMetaData>? Data{ get; set; }
|
||||
|
||||
public string? SeriesTitle{ get; set; }
|
||||
public string? SeasonTitle{ get; set; }
|
||||
public string? EpisodeNumber{ get; set; }
|
||||
public string? EpisodeTitle{ get; set; }
|
||||
public string? SeasonId{ get; set; }
|
||||
public int? Season{ get; set; }
|
||||
public string? ShowId{ get; set; }
|
||||
public string? AbsolutEpisodeNumberE{ get; set; }
|
||||
public string? Image{ get; set; }
|
||||
public bool Paused{ get; set; }
|
||||
public DownloadProgress? DownloadProgress{ get; set; }
|
||||
}
|
||||
|
||||
public class DownloadProgress{
|
||||
|
||||
public bool IsDownloading = false;
|
||||
public bool Done = false;
|
||||
public bool Error = false;
|
||||
public string Doing = string.Empty;
|
||||
|
||||
public int Percent{ get; set; }
|
||||
public double Time{ get; set; }
|
||||
public double DownloadSpeed{ get; set; }
|
||||
}
|
||||
|
||||
public struct CrunchyEpMetaData{
|
||||
public string MediaId{ get; set; }
|
||||
public LanguageItem? Lang{ get; set; }
|
||||
public string? Playback{ get; set; }
|
||||
public List<EpisodeVersion>? Versions{ get; set; }
|
||||
public bool IsSubbed{ get; set; }
|
||||
public bool IsDubbed{ get; set; }
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class Languages{
|
||||
public static readonly LanguageItem[] languages ={
|
||||
new(){ CrLocale = "en-US", Locale = "en", Code = "eng", Name = "English" },
|
||||
new(){ CrLocale = "en-IN", Locale = "en-IN", Code = "eng", Name = "English (India)" },
|
||||
new(){ CrLocale = "es-LA", Locale = "es-419", Code = "spa", Name = "Spanish", Language = "Latin American Spanish" },
|
||||
new(){ CrLocale = "es-419", Locale = "es-419", Code = "spa-419", Name = "Spanish", Language = "Latin American Spanish" },
|
||||
new(){ CrLocale = "es-ES", Locale = "es-ES", Code = "spa-ES", Name = "Castilian", Language = "European Spanish" },
|
||||
new(){ CrLocale = "pt-BR", Locale = "pt-BR", Code = "por", Name = "Portuguese", Language = "Brazilian Portuguese" },
|
||||
new(){ CrLocale = "pt-PT", Locale = "pt-PT", Code = "por", Name = "Portuguese (Portugal)", Language = "Portugues (Portugal)" },
|
||||
new(){ CrLocale = "fr-FR", Locale = "fr", Code = "fra", Name = "French" },
|
||||
new(){ CrLocale = "de-DE", Locale = "de", Code = "deu", Name = "German" },
|
||||
new(){ CrLocale = "ar-ME", Locale = "ar", Code = "ara-ME", Name = "Arabic" },
|
||||
new(){ CrLocale = "ar-SA", Locale = "ar", Code = "ara", Name = "Arabic (Saudi Arabia)" },
|
||||
new(){ CrLocale = "it-IT", Locale = "it", Code = "ita", Name = "Italian" },
|
||||
new(){ CrLocale = "ru-RU", Locale = "ru", Code = "rus", Name = "Russian" },
|
||||
new(){ CrLocale = "tr-TR", Locale = "tr", Code = "tur", Name = "Turkish" },
|
||||
new(){ CrLocale = "hi-IN", Locale = "hi", Code = "hin", Name = "Hindi" },
|
||||
// new(){ locale = "zh", code = "cmn", name = "Chinese (Mandarin, PRC)" },
|
||||
new(){ CrLocale = "zh-CN", Locale = "zh-CN", Code = "zho", Name = "Chinese (Mainland China)" },
|
||||
new(){ CrLocale = "zh-TW", Locale = "zh-TW", Code = "chi", Name = "Chinese (Taiwan)" },
|
||||
new(){ CrLocale = "ko-KR", Locale = "ko", Code = "kor", Name = "Korean" },
|
||||
new(){ CrLocale = "ca-ES", Locale = "ca-ES", Code = "cat", Name = "Catalan" },
|
||||
new(){ CrLocale = "pl-PL", Locale = "pl-PL", Code = "pol", Name = "Polish" },
|
||||
new(){ CrLocale = "th-TH", Locale = "th-TH", Code = "tha", Name = "Thai", Language = "ไทย" },
|
||||
new(){ CrLocale = "ta-IN", Locale = "ta-IN", Code = "tam", Name = "Tamil (India)", Language = "தமிழ்" },
|
||||
new(){ CrLocale = "ms-MY", Locale = "ms-MY", Code = "may", Name = "Malay (Malaysia)", Language = "Bahasa Melayu" },
|
||||
new(){ CrLocale = "vi-VN", Locale = "vi-VN", Code = "vie", Name = "Vietnamese", Language = "Tiếng Việt" },
|
||||
new(){ CrLocale = "id-ID", Locale = "id-ID", Code = "ind", Name = "Indonesian", Language = "Bahasa Indonesia" },
|
||||
new(){ CrLocale = "te-IN", Locale = "te-IN", Code = "tel", Name = "Telugu (India)", Language = "తెలుగు" },
|
||||
new(){ CrLocale = "ja-JP", Locale = "ja", Code = "jpn", Name = "Japanese" },
|
||||
new(){ CrLocale = "id-ID", Locale = "id", Code = "in", Name = "Indonesian " },
|
||||
};
|
||||
|
||||
public static LanguageItem FixAndFindCrLc(string cr_locale){
|
||||
if (string.IsNullOrEmpty(cr_locale)){
|
||||
return new LanguageItem();
|
||||
}
|
||||
string str = FixLanguageTag(cr_locale);
|
||||
return FindLang(str);
|
||||
}
|
||||
|
||||
public static string SubsFile(string fnOutput, string subsIndex, LanguageItem langItem, bool isCC, string ccTag, bool? isSigns = false, string? format = "ass"){
|
||||
subsIndex = (int.Parse(subsIndex) + 1).ToString().PadLeft(2, '0');
|
||||
string fileName = $"{fnOutput}.{subsIndex}.{langItem.Code}";
|
||||
|
||||
//removed .{langItem.language} from file name at end
|
||||
|
||||
if (isCC){
|
||||
fileName += $".{ccTag}";
|
||||
}
|
||||
|
||||
if (isSigns == true){
|
||||
fileName += ".signs";
|
||||
}
|
||||
|
||||
fileName += $".{format}";
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public static string FixLanguageTag(string tag){
|
||||
tag = tag ?? "und";
|
||||
|
||||
var match = Regex.Match(tag, @"^(\w{2})-?(\w{2})$");
|
||||
if (match.Success){
|
||||
|
||||
string tagLang = $"{match.Groups[1].Value}-{match.Groups[2].Value.ToUpper()}";
|
||||
|
||||
var langObj = FindLang(tagLang);
|
||||
if (langObj.CrLocale != "und"){
|
||||
return langObj.CrLocale;
|
||||
}
|
||||
|
||||
return tagLang;
|
||||
}
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
public static List<string> SortTags(List<string> data){
|
||||
var retData = data.Select(e => new LanguageItem{ Locale = e }).ToList();
|
||||
var sorted = SortSubtitles(retData);
|
||||
return sorted.Select(e => e.Locale).ToList();
|
||||
}
|
||||
|
||||
public static LanguageItem FindLang(string crLocale){
|
||||
LanguageItem lang = languages.FirstOrDefault(l => l.CrLocale == crLocale);
|
||||
if (lang.CrLocale != null){
|
||||
return lang;
|
||||
} else{
|
||||
return new LanguageItem{
|
||||
CrLocale = "und",
|
||||
Locale = "un",
|
||||
Code = "und",
|
||||
Name = string.Empty,
|
||||
Language = string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static LanguageItem Locale2language(string locale){
|
||||
LanguageItem? filteredLocale = languages.FirstOrDefault(l => { return l.Locale == locale; });
|
||||
if (filteredLocale != null){
|
||||
return (LanguageItem)filteredLocale;
|
||||
} else{
|
||||
return new LanguageItem{
|
||||
CrLocale = "und",
|
||||
Locale = "un",
|
||||
Code = "und",
|
||||
Name = string.Empty,
|
||||
Language = string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static List<T> SortSubtitles<T>(List<T> data, string sortKey = "locale"){
|
||||
var idx = new Dictionary<string, int>();
|
||||
var tags = new HashSet<string>(languages.Select(e => e.Locale));
|
||||
|
||||
int order = 1;
|
||||
foreach (var l in tags){
|
||||
idx[l] = order++;
|
||||
}
|
||||
|
||||
return data.OrderBy(item => {
|
||||
var property = typeof(T).GetProperty(sortKey, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
||||
if (property == null) throw new ArgumentException($"Property '{sortKey}' not found on type '{typeof(T).Name}'.");
|
||||
|
||||
var value = property.GetValue(item) as string;
|
||||
int index = idx.ContainsKey(value) ? idx[value] : 50;
|
||||
return index;
|
||||
}).ToList();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class PlaybackData{
|
||||
public int Total{ get; set; }
|
||||
public List<Dictionary<string, Dictionary<string, StreamDetails>>>? Data{ get; set; }
|
||||
public PlaybackMeta? Meta{ get; set; }
|
||||
}
|
||||
|
||||
public class StreamDetails{
|
||||
[JsonProperty("hardsub_locale")] public Locale? HardsubLocale{ get; set; }
|
||||
public string? Url{ get; set; }
|
||||
[JsonProperty("hardsub_lang")] public string? HardsubLang{ get; set; }
|
||||
[JsonProperty("audio_lang")] public string? AudioLang{ get; set; }
|
||||
public string? Type{ get; set; }
|
||||
}
|
||||
|
||||
public class PlaybackMeta{
|
||||
[JsonProperty("media_id")] public string? MediaId{ get; set; }
|
||||
public Subtitles? Subtitles{ get; set; }
|
||||
public List<string>? Bifs{ get; set; }
|
||||
public List<PlaybackVersion>? Versions{ get; set; }
|
||||
[JsonProperty("audio_locale")] public Locale? AudioLocale{ get; set; }
|
||||
[JsonProperty("closed_captions")] public Subtitles? ClosedCaptions{ get; set; }
|
||||
public Dictionary<string, object>? Captions{ get; set; }
|
||||
}
|
||||
|
||||
public class SubtitleInfo{
|
||||
public string? Format{ get; set; }
|
||||
public Locale? Locale{ get; set; }
|
||||
public string? Url{ get; set; }
|
||||
}
|
||||
|
||||
public class CrunchyStreams : Dictionary<string, StreamDetails>;
|
||||
|
||||
public class Subtitles : Dictionary<string, SubtitleInfo>;
|
||||
|
||||
public class PlaybackVersion{
|
||||
[JsonProperty("audio_locale")] public Locale AudioLocale{ get; set; } // Assuming Locale is defined elsewhere
|
||||
public string? Guid{ get; set; }
|
||||
[JsonProperty("is_premium_only")] public bool IsPremiumOnly{ get; set; }
|
||||
[JsonProperty("media_guid")] public string? MediaGuid{ get; set; }
|
||||
public bool Original{ get; set; }
|
||||
[JsonProperty("season_guid")] public string? SeasonGuid{ get; set; }
|
||||
public string? Variant{ get; set; }
|
||||
}
|
||||
|
||||
public class StreamDetailsPop{
|
||||
public Locale? HardsubLocale{ get; set; }
|
||||
public string? Url{ get; set; }
|
||||
public string? HardsubLang{ get; set; }
|
||||
public string? AudioLang{ get; set; }
|
||||
public string? Type{ get; set; }
|
||||
public string? Format{ get; set; }
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class PlaybackDataAndroid{
|
||||
public string __class__{ get; set; }
|
||||
public string __href__{ get; set; }
|
||||
public string __resource_key__{ get; set; }
|
||||
public Links __links__{ get; set; }
|
||||
public Dictionary<object, object> __actions__{ get; set; }
|
||||
public string media_id{ get; set; }
|
||||
public Locale audio_locale{ get; set; }
|
||||
public Subtitles subtitles{ get; set; }
|
||||
public Subtitles closed_captions{ get; set; }
|
||||
public List<Dictionary<string, Dictionary<string, StreamDetails>>> streams{ get; set; }
|
||||
public List<string> bifs{ get; set; }
|
||||
public List<PlaybackVersion> versions{ get; set; }
|
||||
public Dictionary<string, object> captions{ get; set; }
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace CRD.Utils.Structs;
|
||||
|
||||
public struct AuthData{
|
||||
public string Username{ get; set; }
|
||||
public string Password{ get; set; }
|
||||
}
|
||||
|
||||
public class DrmAuthData{
|
||||
[JsonProperty("custom_data")] public string? CustomData{ get; set; }
|
||||
public string? Token{ get; set; }
|
||||
}
|
||||
|
||||
public struct Meta{
|
||||
[JsonProperty("versions_considered")]
|
||||
public bool? VersionsConsidered{ get; set; }
|
||||
}
|
||||
|
||||
public struct LanguageItem{
|
||||
[JsonProperty("cr_locale")]
|
||||
public string CrLocale{ get; set; }
|
||||
public string Locale{ get; set; }
|
||||
public string Code{ get; set; }
|
||||
public string Name{ get; set; }
|
||||
public string Language{ get; set; }
|
||||
}
|
||||
|
||||
public struct EpisodeAndLanguage{
|
||||
public List<CrunchyEpisode> Items{ get; set; }
|
||||
public List<LanguageItem> Langs{ get; set; }
|
||||
}
|
||||
|
||||
public struct CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool? but = null, List<string>? e = null, string? s = null){
|
||||
public List<string> DubLang{ get; set; } = dubLang; //lang code
|
||||
public bool? AllEpisodes{ get; set; } = all; // download all episodes
|
||||
public bool? But{ get; set; } = but; //download all except selected episodes
|
||||
public List<string>? E{ get; set; } = e; //episode numbers
|
||||
public string? S{ get; set; } = s; //season id
|
||||
}
|
||||
|
||||
public struct CrunchySeriesList{
|
||||
public List<Episode> List{ get; set; }
|
||||
public Dictionary<string, EpisodeAndLanguage> Data{ get; set; }
|
||||
}
|
||||
|
||||
public struct Episode{
|
||||
public string E{ get; set; }
|
||||
public List<string> Lang{ get; set; }
|
||||
public string Name{ get; set; }
|
||||
public string Season{ get; set; }
|
||||
public string SeasonTitle{ get; set; }
|
||||
public string SeriesTitle{ get; set; }
|
||||
public string EpisodeNum{ get; set; }
|
||||
public string Id{ get; set; }
|
||||
public string Img{ get; set; }
|
||||
public string Description{ get; set; }
|
||||
public string Time{ get; set; }
|
||||
}
|
||||
|
||||
public struct DownloadResponse{
|
||||
public List<DownloadedMedia> Data{ get; set; }
|
||||
public string FileName{ get; set; }
|
||||
public bool Error{ get; set; }
|
||||
}
|
||||
|
||||
public class DownloadedMedia : SxItem{
|
||||
public DownloadMediaType Type{ get; set; }
|
||||
public LanguageItem Lang{ get; set; }
|
||||
public bool IsPrimary{ get; set; }
|
||||
|
||||
public bool? Cc{ get; set; }
|
||||
public bool? Signs{ get; set; }
|
||||
}
|
||||
|
||||
public class SxItem{
|
||||
public LanguageItem Language{ get; set; }
|
||||
public string? Path{ get; set; }
|
||||
public string? File{ get; set; }
|
||||
public string? Title{ get; set; }
|
||||
public Dictionary<string, List<string>>? Fonts{ get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
namespace CRD.Utils.Structs;
|
||||
|
||||
public class Variable{
|
||||
public string Name{ get; set; }
|
||||
public object ReplaceWith{ get; set; }
|
||||
public string Type{ get; set; }
|
||||
public bool Sanitize{ get; set; }
|
||||
|
||||
public Variable(string name, object replaceWith, bool sanitize){
|
||||
Name = name;
|
||||
ReplaceWith = replaceWith;
|
||||
Type = replaceWith.GetType().Name.ToLower();
|
||||
Sanitize = sanitize;
|
||||
}
|
||||
|
||||
public Variable(){
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
|
||||
namespace CRD.Utils.UI;
|
||||
|
||||
public class UiIntToVisibilityConverter : IValueConverter{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture){
|
||||
if (value is int intValue){
|
||||
// Return Visible if intValue is greater than or equal to 1, otherwise Collapsed
|
||||
return intValue >= 1 ? true : false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture){
|
||||
throw new NotImplementedException("This converter only works for one-way binding");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
|
||||
namespace CRD.Utils.UI;
|
||||
|
||||
public class UiSeasonValueConverter : IValueConverter{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture){
|
||||
|
||||
if (value is string stringValue){
|
||||
var parsed = int.TryParse(stringValue, out int seasonNum);
|
||||
if (parsed)
|
||||
return $"Season {seasonNum}";
|
||||
}
|
||||
|
||||
return "Specials";
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture){
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
|
||||
namespace CRD.Utils.UI;
|
||||
|
||||
public class UiValueConverter : IValueConverter{
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture){
|
||||
if (value is bool boolValue){
|
||||
return boolValue ? Symbol.Pause : Symbol.Play;
|
||||
}
|
||||
|
||||
return null; // Or return a default value
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture){
|
||||
if (value is Symbol sym)
|
||||
{
|
||||
return sym == Symbol.Pause;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using CRD.ViewModels;
|
||||
|
||||
namespace CRD;
|
||||
|
||||
public class ViewLocator : IDataTemplate{
|
||||
public Control? Build(object? data){
|
||||
if (data is null)
|
||||
return null;
|
||||
|
||||
var name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||
var type = Type.GetType(name);
|
||||
|
||||
if (type != null){
|
||||
var control = (Control)Activator.CreateInstance(type)!;
|
||||
control.DataContext = data;
|
||||
return control;
|
||||
}
|
||||
|
||||
return new TextBlock{ Text = "Not Found: " + name };
|
||||
}
|
||||
|
||||
public bool Match(object? data){
|
||||
return data is ViewModelBase;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
using System;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Downloader;
|
||||
using CRD.Views.Utils;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public partial class AccountPageViewModel : ViewModelBase{
|
||||
[ObservableProperty] private Bitmap? _profileImage;
|
||||
|
||||
[ObservableProperty] private string _profileName = "";
|
||||
|
||||
[ObservableProperty] private string _loginLogoutText = "";
|
||||
|
||||
|
||||
public AccountPageViewModel(){
|
||||
UpdatetProfile();
|
||||
}
|
||||
|
||||
public void UpdatetProfile(){
|
||||
ProfileName = Crunchyroll.Instance.Profile.Username; // Default or fetched user name
|
||||
LoginLogoutText = Crunchyroll.Instance.Profile.Username == "???" ? "Login" : "Logout"; // Default state
|
||||
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" + Crunchyroll.Instance.Profile.Avatar);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task Button_Press(){
|
||||
if (LoginLogoutText == "Login"){
|
||||
var dialog = new ContentDialog(){
|
||||
Title = "Login",
|
||||
PrimaryButtonText = "Login",
|
||||
CloseButtonText = "Close"
|
||||
};
|
||||
|
||||
var viewModel = new ContentDialogInputLoginViewModel(dialog, this);
|
||||
dialog.Content = new ContentDialogInputLoginView(){
|
||||
DataContext = viewModel
|
||||
};
|
||||
|
||||
_ = await dialog.ShowAsync();
|
||||
} else{
|
||||
await Crunchyroll.Instance.CrAuth.AuthAnonymous();
|
||||
UpdatetProfile();
|
||||
}
|
||||
}
|
||||
|
||||
public async void LoadProfileImage(string imageUrl){
|
||||
try{
|
||||
using (var client = new HttpClient()){
|
||||
var response = await client.GetAsync(imageUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using (var stream = await response.Content.ReadAsStreamAsync()){
|
||||
ProfileImage = new Bitmap(stream);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex){
|
||||
// Handle exceptions
|
||||
Console.WriteLine("Failed to load image: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,255 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Downloader;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using CRD.Views;
|
||||
using ReactiveUI;
|
||||
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public partial class AddDownloadPageViewModel : ViewModelBase{
|
||||
[ObservableProperty] public string _urlInput = "";
|
||||
[ObservableProperty] public string _buttonText = "Enter Url";
|
||||
[ObservableProperty] public bool _addAllEpisodes = false;
|
||||
|
||||
[ObservableProperty] public bool _buttonEnabled = false;
|
||||
[ObservableProperty] public bool _allButtonEnabled = false;
|
||||
[ObservableProperty] public bool _showLoading = false;
|
||||
public ObservableCollection<ItemModel> Items{ get; } = new();
|
||||
public ObservableCollection<ItemModel> SelectedItems{ get; } = new();
|
||||
|
||||
[ObservableProperty] public ComboBoxItem _currentSelectedSeason;
|
||||
public ObservableCollection<ComboBoxItem> SeasonList{ get; } = new();
|
||||
|
||||
private Dictionary<string, List<ItemModel>> episodesBySeason = new();
|
||||
|
||||
private List<string> selectedEpisodes = new();
|
||||
|
||||
private CrunchySeriesList? currentSeriesList;
|
||||
|
||||
public AddDownloadPageViewModel(){
|
||||
// Items.Add(new ItemModel("", "Test", "22:33", "Test", "S1", "E1", 1, new List<string>()));
|
||||
SelectedItems.CollectionChanged += OnSelectedItemsChanged;
|
||||
}
|
||||
|
||||
|
||||
partial void OnUrlInputChanged(string value){
|
||||
if (UrlInput.Length > 9){
|
||||
if (UrlInput.Contains("/watch/concert/") || UrlInput.Contains("/artist/")){
|
||||
MessageBus.Current.SendMessage(new ToastMessage("Concerts / Artists not implemented yet", ToastType.Error, 1));
|
||||
} else if (UrlInput.Contains("/watch/")){
|
||||
//Episode
|
||||
ButtonText = "Add Episode to Queue";
|
||||
ButtonEnabled = true;
|
||||
} else if (UrlInput.Contains("/series/")){
|
||||
//Series
|
||||
ButtonText = "List Episodes";
|
||||
ButtonEnabled = true;
|
||||
} else{
|
||||
ButtonEnabled = false;
|
||||
}
|
||||
} else{
|
||||
ButtonText = "Enter Url";
|
||||
ButtonEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async void OnButtonPress(){
|
||||
if ((selectedEpisodes.Count > 0 || SelectedItems.Count > 0 || AddAllEpisodes)){
|
||||
Console.WriteLine("Added to Queue");
|
||||
|
||||
if (SelectedItems.Count > 0){
|
||||
foreach (var selectedItem in SelectedItems){
|
||||
if (!selectedEpisodes.Contains(selectedItem.AbsolutNum)){
|
||||
selectedEpisodes.Add(selectedItem.AbsolutNum);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSeriesList != null){
|
||||
Crunchyroll.Instance.AddSeriesToQueue(currentSeriesList.Value, new CrunchyMultiDownload(Crunchyroll.Instance.CrunOptions.DubLang, AddAllEpisodes, false, selectedEpisodes));
|
||||
MessageBus.Current.SendMessage(new ToastMessage($"Added episodes to the queue", ToastType.Information, 1));
|
||||
}
|
||||
|
||||
|
||||
UrlInput = "";
|
||||
selectedEpisodes.Clear();
|
||||
SelectedItems.Clear();
|
||||
Items.Clear();
|
||||
currentSeriesList = null;
|
||||
SeasonList.Clear();
|
||||
episodesBySeason.Clear();
|
||||
AllButtonEnabled = false;
|
||||
AddAllEpisodes = false;
|
||||
ButtonText = "Enter Url";
|
||||
ButtonEnabled = false;
|
||||
} else if (UrlInput.Length > 9){
|
||||
episodesBySeason.Clear();
|
||||
SeasonList.Clear();
|
||||
if (UrlInput.Contains("/watch/concert/") || UrlInput.Contains("/artist/")){
|
||||
MessageBus.Current.SendMessage(new ToastMessage("Concerts / Artists not implemented yet", ToastType.Error, 1));
|
||||
} else if (UrlInput.Contains("/watch/")){
|
||||
//Episode
|
||||
|
||||
var match = Regex.Match(UrlInput, "/([^/]+)/watch/([^/]+)");
|
||||
|
||||
if (match.Success){
|
||||
var locale = match.Groups[1].Value; // Capture the locale part
|
||||
var id = match.Groups[2].Value; // Capture the ID part
|
||||
Crunchyroll.Instance.AddEpisodeToQue(id, locale, Crunchyroll.Instance.CrunOptions.DubLang);
|
||||
UrlInput = "";
|
||||
selectedEpisodes.Clear();
|
||||
SelectedItems.Clear();
|
||||
Items.Clear();
|
||||
currentSeriesList = null;
|
||||
SeasonList.Clear();
|
||||
episodesBySeason.Clear();
|
||||
}
|
||||
} else if (UrlInput.Contains("/series/")){
|
||||
//Series
|
||||
var match = Regex.Match(UrlInput, "/([^/]+)/series/([^/]+)");
|
||||
|
||||
if (match.Success){
|
||||
var locale = match.Groups[1].Value; // Capture the locale part
|
||||
var id = match.Groups[2].Value; // Capture the ID part
|
||||
|
||||
if (id.Length != 9){
|
||||
return;
|
||||
}
|
||||
|
||||
ButtonEnabled = false;
|
||||
ShowLoading = true;
|
||||
var list = await Crunchyroll.Instance.CrSeries.ListSeriesId(id,"", new CrunchyMultiDownload(Crunchyroll.Instance.CrunOptions.DubLang, true));
|
||||
ShowLoading = false;
|
||||
if (list != null){
|
||||
currentSeriesList = list;
|
||||
foreach (var episode in currentSeriesList.Value.List){
|
||||
if (episodesBySeason.ContainsKey("S" + episode.Season)){
|
||||
episodesBySeason["S" + episode.Season].Add(new ItemModel(episode.Img, episode.Description, episode.Time, episode.Name, "S" + episode.Season, "E" + episode.EpisodeNum, episode.E,
|
||||
episode.Lang));
|
||||
} else{
|
||||
episodesBySeason.Add("S" + episode.Season, new List<ItemModel>{
|
||||
new ItemModel(episode.Img, episode.Description, episode.Time, episode.Name, "S" + episode.Season, "E" + episode.EpisodeNum, episode.E, episode.Lang)
|
||||
});
|
||||
SeasonList.Add(new ComboBoxItem{ Content = "S" + episode.Season });
|
||||
}
|
||||
}
|
||||
|
||||
CurrentSelectedSeason = SeasonList[0];
|
||||
ButtonEnabled = false;
|
||||
AllButtonEnabled = true;
|
||||
ButtonText = "Select Episodes";
|
||||
} else{
|
||||
ButtonEnabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else{
|
||||
Console.WriteLine("Probably not a url");
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnCurrentSelectedSeasonChanging(ComboBoxItem? oldValue, ComboBoxItem newValue){
|
||||
foreach (var selectedItem in SelectedItems){
|
||||
if (!selectedEpisodes.Contains(selectedItem.AbsolutNum)){
|
||||
selectedEpisodes.Add(selectedItem.AbsolutNum);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEpisodes.Count > 0 || SelectedItems.Count > 0 || AddAllEpisodes){
|
||||
ButtonText = "Add Episodes to Queue";
|
||||
ButtonEnabled = true;
|
||||
} else{
|
||||
ButtonEnabled = false;
|
||||
ButtonText = "Select Episodes";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e){
|
||||
if (selectedEpisodes.Count > 0 || SelectedItems.Count > 0 || AddAllEpisodes){
|
||||
ButtonText = "Add Episodes to Queue";
|
||||
ButtonEnabled = true;
|
||||
} else{
|
||||
ButtonEnabled = false;
|
||||
ButtonText = "Select Episodes";
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnAddAllEpisodesChanged(bool value){
|
||||
if ((selectedEpisodes.Count > 0 || SelectedItems.Count > 0 || AddAllEpisodes)){
|
||||
ButtonText = "Add Episodes to Queue";
|
||||
ButtonEnabled = true;
|
||||
} else{
|
||||
ButtonEnabled = false;
|
||||
ButtonText = "Select Episodes";
|
||||
}
|
||||
}
|
||||
|
||||
async partial void OnCurrentSelectedSeasonChanged(ComboBoxItem? value){
|
||||
if (value == null){
|
||||
return;
|
||||
}
|
||||
|
||||
string key = value.Content + "";
|
||||
Items.Clear();
|
||||
if (episodesBySeason.TryGetValue(key, out var season)){
|
||||
foreach (var episode in season){
|
||||
if (episode.ImageBitmap == null){
|
||||
await episode.LoadImage();
|
||||
Items.Add(episode);
|
||||
if (selectedEpisodes.Contains(episode.AbsolutNum)){
|
||||
SelectedItems.Add(episode);
|
||||
}
|
||||
} else{
|
||||
Items.Add(episode);
|
||||
if (selectedEpisodes.Contains(episode.AbsolutNum)){
|
||||
SelectedItems.Add(episode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ItemModel(string imageUrl, string description, string time, string title, string season, string episode, string absolutNum, List<string> availableAudios){
|
||||
public string ImageUrl{ get; set; } = imageUrl;
|
||||
public Bitmap? ImageBitmap{ get; set; }
|
||||
public string Title{ get; set; } = title;
|
||||
public string Description{ get; set; } = description;
|
||||
public string Time{ get; set; } = time;
|
||||
public string Season{ get; set; } = season;
|
||||
public string Episode{ get; set; } = episode;
|
||||
|
||||
public string AbsolutNum{ get; set; } = absolutNum;
|
||||
|
||||
public string TitleFull{ get; set; } = season + episode + " - " + title;
|
||||
|
||||
public List<string> AvailableAudios{ get; set; } = availableAudios;
|
||||
|
||||
public async Task LoadImage(){
|
||||
try{
|
||||
using (var client = new HttpClient()){
|
||||
var response = await client.GetAsync(ImageUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using (var stream = await response.Content.ReadAsStreamAsync()){
|
||||
ImageBitmap = new Bitmap(stream);
|
||||
}
|
||||
}
|
||||
} catch (Exception ex){
|
||||
// Handle exceptions
|
||||
Console.WriteLine("Failed to load image: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,141 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Downloader;
|
||||
using CRD.Utils.Structs;
|
||||
using DynamicData;
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public partial class CalendarPageViewModel : ViewModelBase{
|
||||
public ObservableCollection<CalendarDay> CalendarDays{ get; set; }
|
||||
|
||||
[ObservableProperty] private ComboBoxItem? _currentCalendarLanguage;
|
||||
[ObservableProperty] private bool? _showLoading = false;
|
||||
|
||||
public ObservableCollection<ComboBoxItem> CalendarLanguage{ get; } = new(){
|
||||
new ComboBoxItem(){ Content = "en-us" },
|
||||
new ComboBoxItem(){ Content = "es" },
|
||||
new ComboBoxItem(){ Content = "es-es" },
|
||||
new ComboBoxItem(){ Content = "pt-br" },
|
||||
new ComboBoxItem(){ Content = "pt-pt" },
|
||||
new ComboBoxItem(){ Content = "fr" },
|
||||
new ComboBoxItem(){ Content = "de" },
|
||||
new ComboBoxItem(){ Content = "ar" },
|
||||
new ComboBoxItem(){ Content = "it" },
|
||||
new ComboBoxItem(){ Content = "ru" },
|
||||
new ComboBoxItem(){ Content = "hi" },
|
||||
};
|
||||
|
||||
private CalendarWeek? currentWeek;
|
||||
|
||||
public CalendarPageViewModel(){
|
||||
CalendarDays = new ObservableCollection<CalendarDay>();
|
||||
CurrentCalendarLanguage = CalendarLanguage.FirstOrDefault(a => a.Content != null && (string)a.Content == Crunchyroll.Instance.CrunOptions.SelectedCalendarLanguage) ?? CalendarLanguage[0];
|
||||
// LoadCalendar(GetThisWeeksMondayDate(), false);
|
||||
}
|
||||
|
||||
private string GetThisWeeksMondayDate(){
|
||||
// Get today's date
|
||||
DateTime today = DateTime.Today;
|
||||
|
||||
// Calculate the number of days to subtract to get to Monday
|
||||
// DayOfWeek.Monday is 1, so if today is Monday, subtract 0 days, if it's Tuesday subtract 1 day, etc.
|
||||
int daysToSubtract = (int)today.DayOfWeek - (int)DayOfWeek.Monday;
|
||||
|
||||
// If today is Sunday (0), it will subtract -1, which we need to adjust to 6 to go back to the previous Monday
|
||||
if (daysToSubtract < 0){
|
||||
daysToSubtract += 7;
|
||||
}
|
||||
|
||||
// Get the date of the most recent Monday
|
||||
DateTime monday = today.AddDays(-daysToSubtract);
|
||||
|
||||
// Format and print the date
|
||||
string formattedDate = monday.ToString("yyyy-MM-dd");
|
||||
|
||||
return formattedDate;
|
||||
}
|
||||
|
||||
public async void LoadCalendar(string mondayDate, bool forceUpdate){
|
||||
ShowLoading = true;
|
||||
CalendarWeek week = await Crunchyroll.Instance.GetCalendarForDate(mondayDate, forceUpdate);
|
||||
if (currentWeek != null && currentWeek == week){
|
||||
ShowLoading = false;
|
||||
return;
|
||||
}
|
||||
currentWeek = week;
|
||||
CalendarDays.Clear();
|
||||
CalendarDays.AddRange(week.CalendarDays);
|
||||
RaisePropertyChanged(nameof(CalendarDays));
|
||||
ShowLoading = false;
|
||||
foreach (var calendarDay in CalendarDays){
|
||||
foreach (var calendarDayCalendarEpisode in calendarDay.CalendarEpisodes){
|
||||
if (calendarDayCalendarEpisode.ImageBitmap == null){
|
||||
calendarDayCalendarEpisode.LoadImage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string NextMonday(DateTime currentMonday){
|
||||
DateTime nextMonday = currentMonday.AddDays(7);
|
||||
return nextMonday.ToString("yyyy-MM-dd");
|
||||
}
|
||||
|
||||
private string PreviousMonday(DateTime currentMonday){
|
||||
DateTime nextMonday = currentMonday.AddDays(-7);
|
||||
return nextMonday.ToString("yyyy-MM-dd");
|
||||
}
|
||||
|
||||
|
||||
[RelayCommand]
|
||||
public void Refresh(){
|
||||
string mondayDate;
|
||||
|
||||
if (currentWeek is{ FirstDayOfWeekString: not null }){
|
||||
mondayDate = currentWeek.FirstDayOfWeekString;
|
||||
} else{
|
||||
mondayDate = GetThisWeeksMondayDate();
|
||||
}
|
||||
|
||||
LoadCalendar(mondayDate, true);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void PrevWeek(){
|
||||
string mondayDate;
|
||||
|
||||
if (currentWeek is{ FirstDayOfWeek: not null }){
|
||||
mondayDate = PreviousMonday((DateTime)currentWeek.FirstDayOfWeek);
|
||||
} else{
|
||||
mondayDate = GetThisWeeksMondayDate();
|
||||
}
|
||||
|
||||
LoadCalendar(mondayDate, false);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void NextWeek(){
|
||||
string mondayDate;
|
||||
|
||||
if (currentWeek is{ FirstDayOfWeek: not null }){
|
||||
mondayDate = NextMonday((DateTime)currentWeek.FirstDayOfWeek);
|
||||
} else{
|
||||
mondayDate = GetThisWeeksMondayDate();
|
||||
}
|
||||
|
||||
LoadCalendar(mondayDate, false);
|
||||
}
|
||||
|
||||
|
||||
partial void OnCurrentCalendarLanguageChanged(ComboBoxItem? value){
|
||||
if (value?.Content != null){
|
||||
Crunchyroll.Instance.CrunOptions.SelectedCalendarLanguage = value.Content.ToString();
|
||||
Refresh();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
using System;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CRD.Downloader;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public partial class ContentDialogInputLoginViewModel : ViewModelBase{
|
||||
private readonly ContentDialog dialog;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _email;
|
||||
|
||||
[ObservableProperty]
|
||||
private string _password;
|
||||
|
||||
private AccountPageViewModel accountPageViewModel;
|
||||
|
||||
public ContentDialogInputLoginViewModel(ContentDialog dialog, AccountPageViewModel accountPageViewModel){
|
||||
if (dialog is null){
|
||||
throw new ArgumentNullException(nameof(dialog));
|
||||
}
|
||||
|
||||
this.dialog = dialog;
|
||||
dialog.Closed += DialogOnClosed;
|
||||
dialog.PrimaryButtonClick += LoginButton;
|
||||
this.accountPageViewModel = accountPageViewModel;
|
||||
}
|
||||
|
||||
private async void LoginButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
|
||||
dialog.PrimaryButtonClick -= LoginButton;
|
||||
await Crunchyroll.Instance.CrAuth.Auth(new AuthData{Password = Password,Username = Email});
|
||||
accountPageViewModel.UpdatetProfile();
|
||||
}
|
||||
|
||||
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
|
||||
dialog.Closed -= DialogOnClosed;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,227 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia;
|
||||
using Avalonia.Media.Imaging;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Downloader;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public partial class DownloadsPageViewModel : ViewModelBase{
|
||||
|
||||
|
||||
public ObservableCollection<DownloadItemModel> Items{ get; }
|
||||
|
||||
[ObservableProperty] public bool _autoDownload;
|
||||
|
||||
private SemaphoreSlim semaphore = new SemaphoreSlim(1, 1);
|
||||
|
||||
public DownloadsPageViewModel(){
|
||||
UpdateListItems();
|
||||
Items = Crunchyroll.Instance.DownloadItemModels;
|
||||
AutoDownload = Crunchyroll.Instance.AutoDownload;
|
||||
Crunchyroll.Instance.Queue.CollectionChanged += UpdateItemListOnRemove;
|
||||
// Items.Add(new DownloadItemModel{Title = "Test - S1E1"});
|
||||
}
|
||||
|
||||
private void UpdateItemListOnRemove(object? sender, NotifyCollectionChangedEventArgs e){
|
||||
if (e.Action == NotifyCollectionChangedAction.Remove){
|
||||
if (e.OldItems != null)
|
||||
foreach (var eOldItem in e.OldItems){
|
||||
var downloadItem = Crunchyroll.Instance.DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(eOldItem));
|
||||
if (downloadItem != null){
|
||||
Crunchyroll.Instance.DownloadItemModels.Remove(downloadItem);
|
||||
} else{
|
||||
Console.WriteLine("Failed to Remove From Preview");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UpdateListItems();
|
||||
}
|
||||
|
||||
|
||||
public void UpdateListItems(){
|
||||
var list = Crunchyroll.Instance.Queue;
|
||||
|
||||
foreach (CrunchyEpMeta crunchyEpMeta in list){
|
||||
var downloadItem = Crunchyroll.Instance.DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(crunchyEpMeta));
|
||||
if (downloadItem != null){
|
||||
downloadItem.Refresh();
|
||||
} else{
|
||||
downloadItem = new DownloadItemModel(crunchyEpMeta);
|
||||
downloadItem.LoadImage();
|
||||
Crunchyroll.Instance.DownloadItemModels.Add(downloadItem);
|
||||
}
|
||||
|
||||
if (downloadItem is{ isDownloading: false, Error: false } && Crunchyroll.Instance.AutoDownload && Crunchyroll.Instance.ActiveDownloads < Crunchyroll.Instance.CrunOptions.SimultaneousDownloads){
|
||||
downloadItem.StartDownload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnAutoDownloadChanged(bool value){
|
||||
Crunchyroll.Instance.AutoDownload = value;
|
||||
if (value){
|
||||
UpdateListItems();
|
||||
}
|
||||
}
|
||||
|
||||
public void Cleanup(){
|
||||
Crunchyroll.Instance.Queue.CollectionChanged -= UpdateItemListOnRemove;
|
||||
}
|
||||
}
|
||||
|
||||
public partial class DownloadItemModel : INotifyPropertyChanged{
|
||||
public string ImageUrl{ get; set; }
|
||||
public Bitmap? ImageBitmap{ get; set; }
|
||||
public string Title{ get; set; }
|
||||
|
||||
public bool isDownloading{ get; set; }
|
||||
public bool Done{ get; set; }
|
||||
public bool Paused{ get; set; }
|
||||
|
||||
public double Percent{ get; set; }
|
||||
public string Time{ get; set; }
|
||||
public string DoingWhat{ get; set; }
|
||||
public string DownloadSpeed{ get; set; }
|
||||
public string InfoText{ get; set; }
|
||||
|
||||
public CrunchyEpMeta epMeta{ get; set; }
|
||||
|
||||
|
||||
public bool Error{ get; set; }
|
||||
|
||||
public DownloadItemModel(CrunchyEpMeta epMetaF){
|
||||
epMeta = epMetaF;
|
||||
|
||||
ImageUrl = epMeta.Image;
|
||||
Title = epMeta.SeriesTitle + " - S" + epMeta.Season + "E" + (epMeta.EpisodeNumber != string.Empty ? epMeta.EpisodeNumber : epMeta.AbsolutEpisodeNumberE) + " - " + epMeta.EpisodeTitle;
|
||||
isDownloading = epMeta.DownloadProgress.IsDownloading || Done;
|
||||
|
||||
Done = epMeta.DownloadProgress.Done;
|
||||
Percent = epMeta.DownloadProgress.Percent;
|
||||
Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time / 1000).ToString(@"hh\:mm\:ss");
|
||||
DownloadSpeed = $"{epMeta.DownloadProgress.DownloadSpeed / 1000000.0:F2}Mb/s";
|
||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
||||
DoingWhat = epMeta.Paused ? "Paused" : Done ? "Done" : epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
|
||||
|
||||
if (epMeta.Data != null) InfoText = "Dub: " + epMeta.Data.First().Lang?.CrLocale + " - " + GetSubtitleString();
|
||||
|
||||
Error = epMeta.DownloadProgress.Error;
|
||||
}
|
||||
|
||||
private string GetSubtitleString(){
|
||||
var hardSubs = Crunchyroll.Instance.CrunOptions.Hslang != "none" ? "Hardsub: " + Crunchyroll.Instance.CrunOptions.Hslang : "";
|
||||
if (hardSubs != string.Empty){
|
||||
return hardSubs;
|
||||
}
|
||||
|
||||
var softSubs = "Softsub: ";
|
||||
|
||||
foreach (var crunOptionsDlSub in Crunchyroll.Instance.CrunOptions.DlSubs){
|
||||
softSubs += crunOptionsDlSub + " ";
|
||||
}
|
||||
|
||||
return softSubs;
|
||||
}
|
||||
|
||||
public void Refresh(){
|
||||
isDownloading = epMeta.DownloadProgress.IsDownloading || Done;
|
||||
Done = epMeta.DownloadProgress.Done;
|
||||
Percent = epMeta.DownloadProgress.Percent;
|
||||
Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time / 1000).ToString(@"hh\:mm\:ss");
|
||||
DownloadSpeed = $"{epMeta.DownloadProgress.DownloadSpeed / 1000000.0:F2}Mb/s";
|
||||
|
||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
||||
DoingWhat = epMeta.Paused ? "Paused" : Done ? "Done" : epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting";
|
||||
|
||||
Error = epMeta.DownloadProgress.Error;
|
||||
|
||||
if (PropertyChanged != null){
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DoingWhat)));
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Error)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
[RelayCommand]
|
||||
public void ToggleIsDownloading(){
|
||||
|
||||
if (isDownloading){
|
||||
//StopDownload();
|
||||
epMeta.Paused = !epMeta.Paused;
|
||||
|
||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||
|
||||
} else{
|
||||
if (epMeta.Paused){
|
||||
epMeta.Paused = false;
|
||||
Paused = epMeta.Paused || !isDownloading && !epMeta.Paused;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||
} else{
|
||||
StartDownload();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (PropertyChanged != null){
|
||||
PropertyChanged.Invoke(this, new PropertyChangedEventArgs("isDownloading"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public async void StartDownload(){
|
||||
if (!isDownloading){
|
||||
isDownloading = true;
|
||||
epMeta.DownloadProgress.IsDownloading = true;
|
||||
Paused = !epMeta.Paused && !isDownloading || epMeta.Paused;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused)));
|
||||
await Crunchyroll.Instance.DownloadEpisode(epMeta, Crunchyroll.Instance.CrunOptions, false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void RemoveFromQueue(){
|
||||
CrunchyEpMeta? downloadItem = Crunchyroll.Instance.Queue.FirstOrDefault(e => e.Equals(epMeta)) ?? null;
|
||||
if (downloadItem != null){
|
||||
Crunchyroll.Instance.Queue.Remove(downloadItem);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LoadImage(){
|
||||
try{
|
||||
using (var client = new HttpClient()){
|
||||
var response = await client.GetAsync(ImageUrl);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using (var stream = await response.Content.ReadAsStreamAsync()){
|
||||
ImageBitmap = new Bitmap(stream);
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ImageBitmap)));
|
||||
}
|
||||
}
|
||||
} catch (Exception ex){
|
||||
// Handle exceptions
|
||||
Console.WriteLine("Failed to load image: " + ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Downloader;
|
||||
using CRD.Views;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public partial class HistoryPageViewModel : ViewModelBase{
|
||||
|
||||
public ObservableCollection<HistorySeries> Items{ get; }
|
||||
[ObservableProperty] private bool? _showLoading = false;
|
||||
[ObservableProperty]
|
||||
public HistorySeries _selectedSeries;
|
||||
|
||||
public HistoryPageViewModel(){
|
||||
Items = Crunchyroll.Instance.HistoryList;
|
||||
|
||||
foreach (var historySeries in Items){
|
||||
if (historySeries.ThumbnailImage == null){
|
||||
historySeries.LoadImage();
|
||||
}
|
||||
historySeries.UpdateNewEpisodes();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
partial void OnSelectedSeriesChanged(HistorySeries value){
|
||||
Crunchyroll.Instance.SelectedSeries = value;
|
||||
MessageBus.Current.SendMessage(new NavigationMessage(typeof(SeriesPageViewModel),false,false));
|
||||
_selectedSeries = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void NavToSeries(){
|
||||
MessageBus.Current.SendMessage(new NavigationMessage(typeof(SeriesPageViewModel),false,false));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async void RefreshAll(){
|
||||
foreach (var historySeries in Items){
|
||||
ShowLoading = true;
|
||||
await historySeries.FetchData("");
|
||||
historySeries.UpdateNewEpisodes();
|
||||
}
|
||||
ShowLoading = false;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using CRD.Downloader;
|
||||
using FluentAvalonia.Styling;
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public partial class MainWindowViewModel : ViewModelBase{
|
||||
private readonly FluentAvaloniaTheme _faTheme;
|
||||
|
||||
public MainWindowViewModel(){
|
||||
|
||||
_faTheme = App.Current.Styles[0] as FluentAvaloniaTheme;
|
||||
|
||||
Init();
|
||||
|
||||
}
|
||||
|
||||
public async void Init(){
|
||||
await Crunchyroll.Instance.Init();
|
||||
|
||||
if (Crunchyroll.Instance.CrunOptions.AccentColor != null){
|
||||
_faTheme.CustomAccentColor = Color.Parse(Crunchyroll.Instance.CrunOptions.AccentColor);
|
||||
}
|
||||
|
||||
if (Crunchyroll.Instance.CrunOptions.Theme == "System"){
|
||||
_faTheme.PreferSystemTheme = true;
|
||||
} else if (Crunchyroll.Instance.CrunOptions.Theme == "Dark"){
|
||||
_faTheme.PreferSystemTheme = false;
|
||||
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
|
||||
} else{
|
||||
_faTheme.PreferSystemTheme = false;
|
||||
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using CRD.Downloader;
|
||||
using CRD.Views;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public partial class SeriesPageViewModel : ViewModelBase{
|
||||
|
||||
|
||||
[ObservableProperty]
|
||||
public HistorySeries _selectedSeries;
|
||||
|
||||
public SeriesPageViewModel(){
|
||||
_selectedSeries = Crunchyroll.Instance.SelectedSeries;
|
||||
|
||||
if (_selectedSeries.ThumbnailImage == null){
|
||||
_selectedSeries.LoadImage();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public async Task UpdateData(string? season){
|
||||
await SelectedSeries.FetchData(season);
|
||||
|
||||
MessageBus.Current.SendMessage(new NavigationMessage(typeof(SeriesPageViewModel),false,true));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
public void NavBack(){
|
||||
SelectedSeries.UpdateNewEpisodes();
|
||||
MessageBus.Current.SendMessage(new NavigationMessage(null,true,false));
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,383 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using System.Net.Mime;
|
||||
using System.Reflection;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CRD.Downloader;
|
||||
using CRD.Utils;
|
||||
using CRD.Utils.Structs;
|
||||
using FluentAvalonia.Styling;
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public partial class SettingsPageViewModel : ViewModelBase{
|
||||
[ObservableProperty] private string _currentVersion = "v1.1";
|
||||
|
||||
[ObservableProperty] private bool _downloadVideo = true;
|
||||
|
||||
[ObservableProperty] private bool _downloadAudio = true;
|
||||
|
||||
[ObservableProperty] private bool _downloadChapters = true;
|
||||
|
||||
[ObservableProperty] private bool _muxToMp4 = false;
|
||||
|
||||
[ObservableProperty] private bool _history = false;
|
||||
|
||||
[ObservableProperty] private int _leadingNumbers = 0;
|
||||
|
||||
[ObservableProperty] private int _simultaneousDownloads = 0;
|
||||
|
||||
[ObservableProperty] private string _fileName = "";
|
||||
|
||||
[ObservableProperty] private string _mkvMergeOptions = "";
|
||||
|
||||
[ObservableProperty] private string _ffmpegOptions = "";
|
||||
|
||||
[ObservableProperty] private string _selectedSubs = "all";
|
||||
|
||||
[ObservableProperty] private ComboBoxItem _selectedHSLang;
|
||||
|
||||
[ObservableProperty] private ComboBoxItem? _selectedDubLang;
|
||||
|
||||
[ObservableProperty] private ComboBoxItem? _selectedVideoQuality;
|
||||
|
||||
[ObservableProperty] private ComboBoxItem? _selectedAudioQuality;
|
||||
|
||||
[ObservableProperty] private ComboBoxItem? _currentAppTheme;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<ListBoxItem> _selectedSubLang = new();
|
||||
|
||||
[ObservableProperty] private bool _useCustomAccent = false;
|
||||
|
||||
[ObservableProperty] private Color _listBoxColor ;
|
||||
[ObservableProperty] private Color _customAccentColor = Colors.SlateBlue;
|
||||
|
||||
public ObservableCollection<Color> PredefinedColors{ get; } = new(){
|
||||
|
||||
Color.FromRgb(255, 185, 0),
|
||||
Color.FromRgb(255, 140, 0),
|
||||
Color.FromRgb(247, 99, 12),
|
||||
Color.FromRgb(202, 80, 16),
|
||||
Color.FromRgb(218, 59, 1),
|
||||
Color.FromRgb(239, 105, 80),
|
||||
Color.FromRgb(209, 52, 56),
|
||||
Color.FromRgb(255, 67, 67),
|
||||
Color.FromRgb(231, 72, 86),
|
||||
Color.FromRgb(232, 17, 35),
|
||||
Color.FromRgb(234, 0, 94),
|
||||
Color.FromRgb(195, 0, 82),
|
||||
Color.FromRgb(227, 0, 140),
|
||||
Color.FromRgb(191, 0, 119),
|
||||
Color.FromRgb(194, 57, 179),
|
||||
Color.FromRgb(154, 0, 137),
|
||||
Color.FromRgb(0, 120, 212),
|
||||
Color.FromRgb(0, 99, 177),
|
||||
Color.FromRgb(142, 140, 216),
|
||||
Color.FromRgb(107, 105, 214),
|
||||
Colors.SlateBlue,
|
||||
Color.FromRgb(135, 100, 184),
|
||||
Color.FromRgb(116, 77, 169),
|
||||
Color.FromRgb(177, 70, 194),
|
||||
Color.FromRgb(136, 23, 152),
|
||||
Color.FromRgb(0, 153, 188),
|
||||
Color.FromRgb(45, 125, 154),
|
||||
Color.FromRgb(0, 183, 195),
|
||||
Color.FromRgb(3, 131, 135),
|
||||
Color.FromRgb(0, 178, 148),
|
||||
Color.FromRgb(1, 133, 116),
|
||||
Color.FromRgb(0, 204, 106),
|
||||
Color.FromRgb(16, 137, 62),
|
||||
Color.FromRgb(122, 117, 116),
|
||||
Color.FromRgb(93, 90, 88),
|
||||
Color.FromRgb(104, 118, 138),
|
||||
Color.FromRgb(81, 92, 107),
|
||||
Color.FromRgb(86, 124, 115),
|
||||
Color.FromRgb(72, 104, 96),
|
||||
Color.FromRgb(73, 130, 5),
|
||||
Color.FromRgb(16, 124, 16),
|
||||
Color.FromRgb(118, 118, 118),
|
||||
Color.FromRgb(76, 74, 72),
|
||||
Color.FromRgb(105, 121, 126),
|
||||
Color.FromRgb(74, 84, 89),
|
||||
Color.FromRgb(100, 124, 100),
|
||||
Color.FromRgb(82, 94, 84),
|
||||
Color.FromRgb(132, 117, 69),
|
||||
Color.FromRgb(126, 115, 95)
|
||||
};
|
||||
|
||||
public ObservableCollection<ComboBoxItem> AppThemes{ get; } = new(){
|
||||
new ComboBoxItem(){ Content = "System" },
|
||||
new ComboBoxItem(){ Content = "Light" },
|
||||
new ComboBoxItem(){ Content = "Dark" },
|
||||
};
|
||||
|
||||
public ObservableCollection<ComboBoxItem> VideoQualityList{ get; } = new(){
|
||||
new ComboBoxItem(){ Content = "best" },
|
||||
new ComboBoxItem(){ Content = "1080" },
|
||||
new ComboBoxItem(){ Content = "720" },
|
||||
new ComboBoxItem(){ Content = "480" },
|
||||
new ComboBoxItem(){ Content = "360" },
|
||||
new ComboBoxItem(){ Content = "240" },
|
||||
new ComboBoxItem(){ Content = "worst" },
|
||||
};
|
||||
|
||||
public ObservableCollection<ComboBoxItem> AudioQualityList{ get; } = new(){
|
||||
new ComboBoxItem(){ Content = "best" },
|
||||
new ComboBoxItem(){ Content = "128kB/s" },
|
||||
new ComboBoxItem(){ Content = "96kB/s" },
|
||||
new ComboBoxItem(){ Content = "64kB/s" },
|
||||
new ComboBoxItem(){ Content = "worst" },
|
||||
};
|
||||
|
||||
public ObservableCollection<ComboBoxItem> HardSubLangList{ get; } = new(){
|
||||
new ComboBoxItem(){ Content = "none" },
|
||||
};
|
||||
|
||||
public ObservableCollection<ComboBoxItem> DubLangList{ get; } = new(){
|
||||
};
|
||||
|
||||
public ObservableCollection<ListBoxItem> SubLangList{ get; } = new(){
|
||||
new ListBoxItem(){ Content = "all" },
|
||||
new ListBoxItem(){ Content = "none" },
|
||||
};
|
||||
|
||||
private readonly FluentAvaloniaTheme _faTheme;
|
||||
|
||||
private bool settingsLoaded = false;
|
||||
|
||||
public SettingsPageViewModel(){
|
||||
|
||||
var version = Assembly.GetExecutingAssembly().GetName().Version;
|
||||
_currentVersion = $"{version?.Major}.{version?.Minor}.{version?.Build}";
|
||||
|
||||
|
||||
_faTheme = App.Current.Styles[0] as FluentAvaloniaTheme;
|
||||
|
||||
foreach (var languageItem in Languages.languages){
|
||||
HardSubLangList.Add(new ComboBoxItem{ Content = languageItem.CrLocale });
|
||||
SubLangList.Add(new ListBoxItem{ Content = languageItem.CrLocale });
|
||||
DubLangList.Add(new ComboBoxItem{ Content = languageItem.CrLocale });
|
||||
}
|
||||
|
||||
CrDownloadOptions options = Crunchyroll.Instance.CrunOptions;
|
||||
|
||||
var softSubLang = SubLangList.Where(a => options.DlSubs.Contains(a.Content)).ToList();
|
||||
|
||||
SelectedSubLang.Clear();
|
||||
foreach (var listBoxItem in softSubLang){
|
||||
SelectedSubLang.Add(listBoxItem);
|
||||
}
|
||||
|
||||
if (SelectedSubLang.Count == 0){
|
||||
SelectedSubs = "none";
|
||||
} else{
|
||||
SelectedSubs = SelectedSubLang[0].Content.ToString();
|
||||
for (var i = 1; i < SelectedSubLang.Count; i++){
|
||||
SelectedSubs += "," + SelectedSubLang[i].Content;
|
||||
}
|
||||
}
|
||||
|
||||
ComboBoxItem? hsLang = HardSubLangList.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Hslang) ?? null;
|
||||
SelectedHSLang = hsLang ?? HardSubLangList[0];
|
||||
|
||||
ComboBoxItem? dubLang = DubLangList.FirstOrDefault(a => a.Content != null && (string)a.Content == options.DubLang[0]) ?? null;
|
||||
SelectedDubLang = dubLang ?? DubLangList[0];
|
||||
|
||||
DownloadVideo = !options.Novids;
|
||||
DownloadAudio = !options.Noaudio;
|
||||
DownloadChapters = options.Chapters;
|
||||
MuxToMp4 = options.Mp4;
|
||||
LeadingNumbers = options.Numbers;
|
||||
FileName = options.FileName;
|
||||
SimultaneousDownloads = options.SimultaneousDownloads;
|
||||
|
||||
ComboBoxItem? qualityAudio = AudioQualityList.FirstOrDefault(a => a.Content != null && (string)a.Content == options.QualityAudio) ?? null;
|
||||
SelectedAudioQuality = qualityAudio ?? AudioQualityList[0];
|
||||
|
||||
ComboBoxItem? qualityVideo = VideoQualityList.FirstOrDefault(a => a.Content != null && (string)a.Content == options.QualityVideo) ?? null;
|
||||
SelectedVideoQuality = qualityVideo ?? VideoQualityList[0];
|
||||
|
||||
ComboBoxItem? theme = AppThemes.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Theme) ?? null;
|
||||
CurrentAppTheme = theme ?? AppThemes[0];
|
||||
|
||||
if (options.AccentColor != CustomAccentColor.ToString()){
|
||||
UseCustomAccent = true;
|
||||
}
|
||||
|
||||
History = options.History;
|
||||
|
||||
//TODO - Mux Options
|
||||
|
||||
SelectedSubLang.CollectionChanged += Changes;
|
||||
|
||||
settingsLoaded = true;
|
||||
}
|
||||
|
||||
private void UpdateSettings(){
|
||||
|
||||
if (!settingsLoaded){
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedSubLang.Count == 0){
|
||||
SelectedSubs = "none";
|
||||
} else{
|
||||
SelectedSubs = SelectedSubLang[0].Content.ToString();
|
||||
for (var i = 1; i < SelectedSubLang.Count; i++){
|
||||
SelectedSubs += "," + SelectedSubLang[i].Content;
|
||||
}
|
||||
}
|
||||
|
||||
Crunchyroll.Instance.CrunOptions.Novids = !DownloadVideo;
|
||||
Crunchyroll.Instance.CrunOptions.Noaudio = !DownloadAudio;
|
||||
Crunchyroll.Instance.CrunOptions.Chapters = DownloadChapters;
|
||||
Crunchyroll.Instance.CrunOptions.Mp4 = MuxToMp4;
|
||||
Crunchyroll.Instance.CrunOptions.Numbers = LeadingNumbers;
|
||||
Crunchyroll.Instance.CrunOptions.FileName = FileName;
|
||||
|
||||
|
||||
List<string> softSubs = new List<string>();
|
||||
foreach (var listBoxItem in SelectedSubLang){
|
||||
softSubs.Add(listBoxItem.Content + "");
|
||||
}
|
||||
|
||||
Crunchyroll.Instance.CrunOptions.DlSubs = softSubs;
|
||||
|
||||
string hslang = SelectedHSLang.Content + "";
|
||||
|
||||
Crunchyroll.Instance.CrunOptions.Hslang = hslang != "none" ? Languages.FindLang(hslang).Locale : hslang;
|
||||
|
||||
if (SelectedDubLang != null){
|
||||
string dublang = SelectedDubLang.Content + "";
|
||||
|
||||
Crunchyroll.Instance.CrunOptions.DubLang = new List<string>{ dublang };
|
||||
}
|
||||
|
||||
Crunchyroll.Instance.CrunOptions.SimultaneousDownloads = SimultaneousDownloads;
|
||||
|
||||
|
||||
Crunchyroll.Instance.CrunOptions.QualityAudio = SelectedAudioQuality?.Content + "";
|
||||
Crunchyroll.Instance.CrunOptions.QualityVideo = SelectedVideoQuality?.Content + "";
|
||||
Crunchyroll.Instance.CrunOptions.Theme = CurrentAppTheme?.Content + "";
|
||||
|
||||
Crunchyroll.Instance.CrunOptions.AccentColor = _faTheme.CustomAccentColor.ToString();
|
||||
|
||||
Crunchyroll.Instance.CrunOptions.History = History;
|
||||
|
||||
//TODO - Mux Options
|
||||
|
||||
CfgManager.WriteSettingsToFile();
|
||||
|
||||
// Console.WriteLine("Updated Settings");
|
||||
}
|
||||
|
||||
partial void OnCurrentAppThemeChanged(ComboBoxItem? value){
|
||||
if (value?.Content?.ToString() == "System"){
|
||||
_faTheme.PreferSystemTheme = true;
|
||||
} else if (value?.Content?.ToString() == "Dark"){
|
||||
_faTheme.PreferSystemTheme = false;
|
||||
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
|
||||
} else{
|
||||
_faTheme.PreferSystemTheme = false;
|
||||
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
|
||||
}
|
||||
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnUseCustomAccentChanged(bool value){
|
||||
if (value){
|
||||
if (_faTheme.TryGetResource("SystemAccentColor", null, out var curColor)){
|
||||
CustomAccentColor = (Color)curColor;
|
||||
ListBoxColor = CustomAccentColor;
|
||||
|
||||
RaisePropertyChanged(nameof(CustomAccentColor));
|
||||
RaisePropertyChanged(nameof(ListBoxColor));
|
||||
}
|
||||
} else{
|
||||
CustomAccentColor = default;
|
||||
ListBoxColor = default;
|
||||
UpdateAppAccentColor(Colors.SlateBlue);
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnListBoxColorChanged(Color value){
|
||||
if (value != null){
|
||||
CustomAccentColor = value;
|
||||
RaisePropertyChanged(nameof(CustomAccentColor));
|
||||
|
||||
UpdateAppAccentColor(value);
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnCustomAccentColorChanged(Color value){
|
||||
ListBoxColor = value;
|
||||
RaisePropertyChanged(nameof(ListBoxColor));
|
||||
UpdateAppAccentColor(value);
|
||||
}
|
||||
|
||||
private void UpdateAppAccentColor(Color? color){
|
||||
_faTheme.CustomAccentColor = color;
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
|
||||
partial void OnSelectedDubLangChanged(ComboBoxItem? value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
private void Changes(object? sender, NotifyCollectionChangedEventArgs e){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnDownloadAudioChanged(bool value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnDownloadChaptersChanged(bool value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnDownloadVideoChanged(bool value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnFileNameChanged(string value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnLeadingNumbersChanged(int value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnMuxToMp4Changed(bool value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnSelectedHSLangChanged(ComboBoxItem value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnSimultaneousDownloadsChanged(int value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnSelectedAudioQualityChanged(ComboBoxItem? value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnSelectedVideoQualityChanged(ComboBoxItem? value){
|
||||
UpdateSettings();
|
||||
}
|
||||
|
||||
partial void OnHistoryChanged(bool value){
|
||||
UpdateSettings();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace CRD.ViewModels;
|
||||
|
||||
public class ViewModelBase : ObservableObject{
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
protected void RaisePropertyChanged(string propName){
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels"
|
||||
x:DataType="vm:AccountPageViewModel"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="CRD.Views.AccountPageView">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:AccountPageViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Center">
|
||||
|
||||
<!-- Profile Image -->
|
||||
<Image Width="170" Height="170" Margin="20"
|
||||
Source="{Binding ProfileImage}">
|
||||
<Image.Clip>
|
||||
<EllipseGeometry Rect="0,0,170,170" />
|
||||
</Image.Clip>
|
||||
</Image>
|
||||
|
||||
<!-- Profile Name -->
|
||||
<TextBlock Text="{Binding ProfileName}" TextAlignment="Center" FontSize="20" Margin="10" />
|
||||
|
||||
<!-- Login/Logout Button -->
|
||||
<Button Content="{Binding LoginLogoutText}" Width="170" Margin="20" Command="{Binding Button_PressCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
|
||||
</UserControl>
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace CRD.Views;
|
||||
|
||||
public partial class AccountPageView : UserControl{
|
||||
public AccountPageView(){
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels"
|
||||
x:DataType="vm:AddDownloadPageViewModel"
|
||||
x:Class="CRD.Views.AddDownloadPageView">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:AddDownloadPageViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" /> <!-- For the TextBox -->
|
||||
<RowDefinition Height="Auto" /> <!-- For Grid with buttons/checkbox -->
|
||||
<RowDefinition Height="*" /> <!-- For the ListBox to take remaining space -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
|
||||
<!-- Text Input Field -->
|
||||
<TextBox Grid.Row="0" Watermark="Enter series or episode url" Text="{Binding UrlInput}" Margin="10"
|
||||
VerticalAlignment="Top" />
|
||||
|
||||
<Grid Grid.Row="1" Margin="10 0 10 0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- Button -->
|
||||
|
||||
<Button Grid.Column="0" IsEnabled="{Binding ButtonEnabled}" Width="200" Command="{Binding OnButtonPress}"
|
||||
Content="{Binding ButtonText}">
|
||||
</Button>
|
||||
|
||||
<CheckBox Grid.Column="1" IsEnabled="{Binding AllButtonEnabled}" IsChecked="{Binding AddAllEpisodes}"
|
||||
Content="All" Margin="5 0 0 0">
|
||||
</CheckBox>
|
||||
|
||||
<!-- ComboBox -->
|
||||
<ComboBox Grid.Column="2" MinWidth="200" SelectedItem="{Binding CurrentSelectedSeason}"
|
||||
ItemsSource="{Binding SeasonList}">
|
||||
</ComboBox>
|
||||
|
||||
</Grid>
|
||||
|
||||
|
||||
<Grid Grid.Row="2">
|
||||
<!-- Spinner Style ProgressBar -->
|
||||
<ProgressBar IsIndeterminate="True"
|
||||
Value="50"
|
||||
Maximum="100"
|
||||
MaxWidth="100"
|
||||
IsVisible="{Binding ShowLoading}"
|
||||
>
|
||||
</ProgressBar>
|
||||
</Grid>
|
||||
|
||||
<!-- ListBox with Custom Elements -->
|
||||
<ListBox Grid.Row="2" Margin="10" SelectionMode="Multiple,Toggle" VerticalAlignment="Stretch"
|
||||
SelectedItems="{Binding SelectedItems}" ItemsSource="{Binding Items}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type vm:ItemModel}">
|
||||
<StackPanel>
|
||||
<Border Padding="10" Margin="5" BorderThickness="1">
|
||||
<Grid Margin="10" VerticalAlignment="Top">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- Image -->
|
||||
<Image Grid.Column="0" Width="208" Height="117" Source="{Binding ImageBitmap}"
|
||||
Stretch="Fill" />
|
||||
|
||||
<!-- Text Content -->
|
||||
<Grid Grid.Column="1" Margin="10" VerticalAlignment="Top">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" /> <!-- Takes up most space for the title -->
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<!-- Takes up space as needed for the time -->
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Column="0" Text="{Binding TitleFull}" FontWeight="Bold"
|
||||
FontSize="16"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding Time}" FontStyle="Italic"
|
||||
HorizontalAlignment="Right" TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Column="0" Grid.Row="1" Grid.ColumnSpan="2"
|
||||
Text="{Binding Description}"
|
||||
FontStyle="Italic" Opacity="0.8" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Background="LightGray" Height="1" Margin="0,5" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace CRD.Views;
|
||||
|
||||
public partial class AddDownloadPageView : UserControl{
|
||||
public AddDownloadPageView(){
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,143 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels"
|
||||
x:DataType="vm:CalendarPageViewModel"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="CRD.Views.CalendarPageView">
|
||||
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" /> <!-- For the button -->
|
||||
<RowDefinition Height="*" /> <!-- For the ListBox to take remaining space -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" /> <!-- Takes up most space for the title -->
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" /> <!-- Takes up most space for the title -->
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Button Grid.Row="0" Grid.Column="0" Margin="10 10 0 0" HorizontalAlignment="Center" Command="{Binding PrevWeek}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:SymbolIcon Symbol="ChevronLeft" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<StackPanel Grid.Row="0" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0 10 0 0">
|
||||
<Button HorizontalAlignment="Center" Command="{Binding Refresh}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:SymbolIcon Symbol="Refresh" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<ComboBox HorizontalAlignment="Center" Margin="10 0 0 0" MinWidth="200"
|
||||
SelectedItem="{Binding CurrentCalendarLanguage}"
|
||||
ItemsSource="{Binding CalendarLanguage}">
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
<Button Grid.Row="0" Grid.Column="2" Margin="0 0 10 0" HorizontalAlignment="Center" Command="{Binding NextWeek}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:SymbolIcon Symbol="ChevronRight" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Grid Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3">
|
||||
<!-- Spinner Style ProgressBar -->
|
||||
<ProgressBar IsIndeterminate="True"
|
||||
Value="50"
|
||||
Maximum="100"
|
||||
MaxWidth="100"
|
||||
IsVisible="{Binding ShowLoading}">
|
||||
</ProgressBar>
|
||||
</Grid>
|
||||
|
||||
<ItemsControl Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" IsVisible="{Binding !ShowLoading}"
|
||||
ItemsSource="{Binding CalendarDays}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<!-- This UniformGrid will serve as the panel for the ItemsControl, arranging items horizontally -->
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid Columns="7" />
|
||||
<!-- This ensures that we have 7 columns, one for each day of the week -->
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" /> <!-- For the header with date and day name -->
|
||||
<RowDefinition Height="*" /> <!-- For the ListBox, taking up the rest of the space -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Border Grid.Row="0" Padding="4">
|
||||
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
Text="{Binding DateTime, StringFormat='dd.MM.yyyy'}" FontWeight="Bold" />
|
||||
<TextBlock HorizontalAlignment="Center" Text="{Binding DayName}" Margin="4,0,0,0" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ListBox for episodes -->
|
||||
<ListBox Grid.Row="1" ItemsSource="{Binding CalendarEpisodes}"> <!-- Adjust MaxHeight as needed -->
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Padding="10" Margin="5" Height="200">
|
||||
<StackPanel Orientation="Vertical">
|
||||
<TextBlock HorizontalAlignment="Center"
|
||||
Text="{Binding DateTime, StringFormat='hh:mm tt'}"
|
||||
Margin="0,0,0,0" />
|
||||
<Grid HorizontalAlignment="Center">
|
||||
<Image HorizontalAlignment="Center" Source="{Binding ImageBitmap}" />
|
||||
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Left">
|
||||
<TextBlock VerticalAlignment="Center" TextAlignment="Center" Margin="0 0 5 0" Width="30" Height="30"
|
||||
Background="Black" Opacity="0.8" Text="{Binding EpisodeNumber}"
|
||||
Padding="0,5,0,0"/>
|
||||
</StackPanel>
|
||||
<StackPanel VerticalAlignment="Bottom" HorizontalAlignment="Right"
|
||||
IsVisible="{Binding IsPremiumOnly}" Margin="0,0,5,5">
|
||||
<Canvas Width="28" Height="28">
|
||||
<Ellipse Fill="#40FFFFFF" Width="28" Height="28"/>
|
||||
<Viewbox Width="24" Height="24" Stretch="Uniform" Canvas.Left="2" Canvas.Top="2">
|
||||
<Canvas Width="50" Height="50"> <!-- Ensure inner canvas is large enough to hold the path data -->
|
||||
<Path Fill="#f78c25"
|
||||
Stroke="#f78c25"
|
||||
StrokeThickness="1"
|
||||
Data="M35.7,36.2H12.3c-0.7,0-1.4-0.5-1.6-1.2L6.1,18.6c-0.2-0.6,0-1.3,0.5-1.7c0.5-0.4,1.2-0.5,1.8-0.2l8.1,4.1 l6.2-8.3c0.3-0.4,0.8-0.7,1.3-0.7h0c0.5,0,1,0.2,1.3,0.7l6.2,8.3l8.2-4.1c0.6-0.3,1.3-0.2,1.8,0.2c0.5,0.4,0.7,1.1,0.5,1.7 L37.3,35C37.1,35.7,36.4,36.2,35.7,36.2z"/>
|
||||
</Canvas>
|
||||
</Viewbox>
|
||||
</Canvas>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
|
||||
<TextBlock HorizontalAlignment="Center" Text="{Binding SeasonName}"
|
||||
TextWrapping="NoWrap"
|
||||
Margin="0,0,0,0">
|
||||
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="{Binding SeasonName}" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
|
||||
</TextBlock>
|
||||
<Button HorizontalAlignment="Center" Content="Download"
|
||||
IsEnabled="{Binding HasPassed}" Command="{Binding AddEpisodeToQue}"
|
||||
CommandParameter="{Binding EpisodeUrl}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
|
||||
</Grid>
|
||||
|
||||
|
||||
</UserControl>
|
|
@ -0,0 +1,12 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace CRD.Views;
|
||||
|
||||
public partial class CalendarPageView : UserControl{
|
||||
public CalendarPageView(){
|
||||
InitializeComponent();
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels"
|
||||
x:DataType="vm:ContentDialogInputLoginViewModel"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="CRD.Views.Utils.ContentDialogInputLoginView">
|
||||
|
||||
<StackPanel Spacing="10" MinWidth="400">
|
||||
<!-- <TextBlock>E-Mail</TextBlock> -->
|
||||
<TextBox Watermark="E-Mail" Text="{Binding Email}"></TextBox>
|
||||
<!-- <TextBlock>Password</TextBlock> -->
|
||||
<TextBox Watermark="Password" Text="{Binding Password}"></TextBox>
|
||||
</StackPanel>
|
||||
</UserControl>
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace CRD.Views.Utils;
|
||||
|
||||
public partial class ContentDialogInputLoginView : UserControl{
|
||||
public ContentDialogInputLoginView(){
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,118 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels"
|
||||
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
x:DataType="vm:DownloadsPageViewModel"
|
||||
x:Class="CRD.Views.DownloadsPageView"
|
||||
xmlns:local="clr-namespace:CRD.Utils"
|
||||
xmlns:ui="clr-namespace:CRD.Utils.UI">
|
||||
|
||||
<UserControl.Resources>
|
||||
<ui:UiValueConverter x:Key="UiValueConverter"/>
|
||||
</UserControl.Resources>
|
||||
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" /> <!-- For the TextBox -->
|
||||
<RowDefinition Height="*" /> <!-- For the ListBox to take remaining space -->
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<StackPanel Grid.Row="0">
|
||||
<!-- <Button Click="Button_OnClick">Test Download</Button> -->
|
||||
<ToggleSwitch HorizontalAlignment="Right" Margin="0 0 10 0 " IsChecked="{Binding AutoDownload}" OffContent="Auto Download" OnContent="Auto Download"></ToggleSwitch>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
<ListBox Grid.Row="1" Focusable="False" Margin="10" VerticalAlignment="Stretch" ItemsSource="{Binding Items}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate DataType="{x:Type vm:DownloadItemModel}">
|
||||
<StackPanel>
|
||||
<Border Padding="10" Margin="5" BorderThickness="1">
|
||||
<Grid Margin="10" VerticalAlignment="Top">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<!-- Image -->
|
||||
<Image Grid.Column="0" Width="208" Height="117" Source="{Binding ImageBitmap}"
|
||||
Stretch="Fill" />
|
||||
|
||||
<!-- Text Content -->
|
||||
<Grid Grid.Column="1" Margin="10" >
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" /> <!-- Takes up most space for the title -->
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" MaxHeight="117" Text="{Binding Title}" FontWeight="Bold" FontSize="16"
|
||||
TextWrapping="Wrap" VerticalAlignment="Top" />
|
||||
|
||||
<!-- <TextBlock Grid.Row="1" Grid.Column="0" MaxHeight="117" Text="{Binding InfoText}" Opacity="0.8" -->
|
||||
<!-- TextWrapping="Wrap" VerticalAlignment="Center" /> -->
|
||||
|
||||
<Button Grid.Row="0" Grid.Column="1" Margin="0 0 5 0" IsVisible="{Binding !Error}" Command="{Binding ToggleIsDownloading}" FontStyle="Italic"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:SymbolIcon Symbol="{Binding
|
||||
!Paused, Converter={StaticResource UiValueConverter}}" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Row="0" Grid.Column="1" Margin="0 0 5 0" IsVisible="{Binding Error}" Command="{Binding ToggleIsDownloading}" FontStyle="Italic"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:SymbolIcon Symbol="Refresh" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Row="0" Grid.Column="2" Command="{Binding RemoveFromQueue}" FontStyle="Italic"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:SymbolIcon Symbol="Delete" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
|
||||
<ProgressBar Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="3" VerticalAlignment="Bottom" Margin="0 0 0 10" Height="5" Value="{Binding Percent}"></ProgressBar>
|
||||
|
||||
|
||||
<Grid Grid.Row="3" Grid.Column="0" Grid.ColumnSpan="3" VerticalAlignment="Bottom" >
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" /> <!-- Takes up most space for the title -->
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Bottom" Text="{Binding DoingWhat}"
|
||||
Opacity="1" TextWrapping="NoWrap" />
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" VerticalAlignment="Bottom" Margin="0 0 10 0" Text="{Binding Time}"
|
||||
Opacity="0.8" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Grid.Row="0" Grid.Column="2" VerticalAlignment="Bottom" Text="{Binding DownloadSpeed}"
|
||||
Opacity="0.8" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Background="LightGray" Height="1" Margin="0,5" HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
</UserControl>
|
|
@ -0,0 +1,24 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using CRD.Downloader;
|
||||
using CRD.ViewModels;
|
||||
|
||||
namespace CRD.Views;
|
||||
|
||||
public partial class DownloadsPageView : UserControl{
|
||||
public DownloadsPageView(){
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e){
|
||||
base.OnDetachedFromVisualTree(e);
|
||||
if (DataContext is DownloadsPageViewModel vm){
|
||||
vm.Cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private void Button_OnClick(object? sender, RoutedEventArgs e){
|
||||
// Crunchy.Instance.TestMethode();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,63 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels"
|
||||
xmlns:ui="clr-namespace:CRD.Utils.UI"
|
||||
x:DataType="vm:HistoryPageViewModel"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="CRD.Views.HistoryPageView">
|
||||
|
||||
<UserControl.Resources>
|
||||
<ui:UiIntToVisibilityConverter x:Key="UiIntToVisibilityConverter" />
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid>
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
|
||||
<Button Grid.Row="0" Grid.Column="0" Command="{Binding RefreshAll}" Margin="10">Refresh All</Button>
|
||||
<Grid Grid.Row="1" Grid.Column="0">
|
||||
<!-- Spinner Style ProgressBar -->
|
||||
<ProgressBar IsIndeterminate="True"
|
||||
Value="50"
|
||||
Maximum="100"
|
||||
MaxWidth="100"
|
||||
IsVisible="{Binding ShowLoading}">
|
||||
</ProgressBar>
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" ItemsSource="{Binding Items}" IsVisible="{Binding !ShowLoading}" SelectedItem="{Binding SelectedSeries}" Margin="5">
|
||||
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal"></WrapPanel>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Vertical" HorizontalAlignment="Center" MaxWidth="250" Width="250"
|
||||
MaxHeight="400" Height="400" Margin="5">
|
||||
<Grid>
|
||||
<Image Source="{Binding ThumbnailImage}" Width="240" Height="360"></Image>
|
||||
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Right" IsVisible="{Binding NewEpisodes, Converter={StaticResource UiIntToVisibilityConverter}}">
|
||||
<TextBlock VerticalAlignment="Center" TextAlignment="Center" Margin="0 0 5 0" Width="30" Height="30"
|
||||
Background="Black" Opacity="0.8" Text="{Binding NewEpisodes}"
|
||||
Padding="0,5,0,0"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<TextBlock HorizontalAlignment="Center" Text="{Binding SeriesTitle}" TextWrapping="NoWrap"
|
||||
Margin="4,0,0,0">
|
||||
</TextBlock>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace CRD.Views;
|
||||
|
||||
public partial class HistoryPageView : UserControl{
|
||||
public HistoryPageView(){
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,83 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels"
|
||||
xmlns:ui="using:FluentAvalonia.UI.Controls"
|
||||
xmlns:views="clr-namespace:CRD.Views"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="CRD.Views.MainWindow"
|
||||
x:DataType="vm:MainWindowViewModel"
|
||||
Icon="/Assets/app_icon.ico"
|
||||
Title="Crunchy-Downloader">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:MainWindowViewModel />
|
||||
</Design.DataContext>
|
||||
<Grid>
|
||||
<ContentControl x:Name="MainContent">
|
||||
<Grid RowDefinitions="Auto, *">
|
||||
|
||||
|
||||
<Border Grid.Row="0" Height="32">
|
||||
<Grid Name="TitleBarHost"
|
||||
ColumnDefinitions="Auto,Auto,*,Auto"
|
||||
Background="Transparent">
|
||||
<Image Margin="12 4"
|
||||
IsHitTestVisible="False"
|
||||
Source="../Assets/app_icon.ico"
|
||||
Width="18" Height="18"
|
||||
DockPanel.Dock="Left"
|
||||
Name="WindowIcon"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality">
|
||||
<Image.IsVisible>
|
||||
<OnPlatform Default="False">
|
||||
<On Options="Windows" Content="True" />
|
||||
</OnPlatform>
|
||||
</Image.IsVisible>
|
||||
</Image>
|
||||
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Title, RelativeSource={RelativeSource FindAncestor, AncestorType=Window}}"
|
||||
VerticalAlignment="Center"
|
||||
>
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
|
||||
<ui:NavigationView Grid.Row="1"
|
||||
IsPaneOpen="False"
|
||||
IsPaneToggleButtonVisible="False"
|
||||
IsSettingsVisible="False"
|
||||
CompactPaneLength="72"
|
||||
Name="NavView"
|
||||
SelectionChanged="NavView_SelectionChanged">
|
||||
<ui:NavigationView.MenuItems>
|
||||
<ui:NavigationViewItem Classes="SampleAppNav" Content="Downloads" Tag="DownloadQueue"
|
||||
IconSource="Download">
|
||||
</ui:NavigationViewItem>
|
||||
<ui:NavigationViewItem Classes="SampleAppNav" Content="Add Download" Tag="AddDownload"
|
||||
IconSource="Add" />
|
||||
<ui:NavigationViewItem Classes="SampleAppNav" Content="Calendar" Tag="Calendar"
|
||||
IconSource="Calendar" />
|
||||
<ui:NavigationViewItem Classes="SampleAppNav" Content="History" Tag="History"
|
||||
IconSource="Library" />
|
||||
</ui:NavigationView.MenuItems>
|
||||
<ui:NavigationView.FooterMenuItems>
|
||||
<ui:NavigationViewItem Classes="SampleAppNav" Content="Account" Tag="Account"
|
||||
IconSource="Contact" />
|
||||
<ui:NavigationViewItem Classes="SampleAppNav" Content="Settings" Tag="Settings"
|
||||
IconSource="Settings" />
|
||||
</ui:NavigationView.FooterMenuItems>
|
||||
</ui:NavigationView>
|
||||
|
||||
</Grid>
|
||||
</ContentControl>
|
||||
|
||||
<!-- Your main window content -->
|
||||
<views:ToastNotification x:Name="Toast" IsVisible="False" />
|
||||
</Grid>
|
||||
|
||||
|
||||
</Window>
|
|
@ -0,0 +1,117 @@
|
|||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Reactive.Disposables;
|
||||
using Avalonia.Controls;
|
||||
using CRD.Downloader;
|
||||
using CRD.ViewModels;
|
||||
using CRD.Views.Utils;
|
||||
using FluentAvalonia.Core;
|
||||
using FluentAvalonia.UI.Controls;
|
||||
using FluentAvalonia.UI.Navigation;
|
||||
using FluentAvalonia.UI.Windowing;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace CRD.Views;
|
||||
|
||||
public partial class MainWindow : AppWindow{
|
||||
private Stack<object> navigationStack = new Stack<object>();
|
||||
|
||||
public MainWindow(){
|
||||
InitializeComponent();
|
||||
|
||||
TitleBar.ExtendsContentIntoTitleBar = true;
|
||||
TitleBar.TitleBarHitTestType = TitleBarHitTestType.Complex;
|
||||
|
||||
|
||||
//select first element as default
|
||||
var nv = this.FindControl<NavigationView>("NavView");
|
||||
nv.SelectedItem = nv.MenuItems.ElementAt(0);
|
||||
|
||||
MessageBus.Current.Listen<NavigationMessage>()
|
||||
.Subscribe(message => {
|
||||
if (message.Refresh){
|
||||
navigationStack.Pop();
|
||||
var viewModel = Activator.CreateInstance(message.ViewModelType);
|
||||
navigationStack.Push(viewModel);
|
||||
nv.Content = viewModel;
|
||||
} else if (!message.Back && message.ViewModelType != null){
|
||||
var viewModel = Activator.CreateInstance(message.ViewModelType);
|
||||
navigationStack.Push(viewModel);
|
||||
nv.Content = viewModel;
|
||||
} else{
|
||||
navigationStack.Pop();
|
||||
var viewModel = navigationStack.Peek();
|
||||
nv.Content = viewModel;
|
||||
}
|
||||
});
|
||||
|
||||
MessageBus.Current.Listen<ToastMessage>()
|
||||
.Subscribe(message => ShowToast(message.Message, message.Type, message.Seconds));
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
public static void ShowError(string message){
|
||||
var window = new ErrorWindow();
|
||||
window.SetErrorMessage(message);
|
||||
window.Show(); // 'this' is a reference to the parent window, if applicable
|
||||
}
|
||||
|
||||
public void ShowToast(string message, ToastType type, int durationInSeconds = 5){
|
||||
this.FindControl<ToastNotification>("Toast").Show(message, type, durationInSeconds);
|
||||
}
|
||||
|
||||
|
||||
private void NavView_SelectionChanged(object? sender, NavigationViewSelectionChangedEventArgs e){
|
||||
if (sender is NavigationView navView){
|
||||
var selectedItem = navView.SelectedItem as NavigationViewItem;
|
||||
if (selectedItem != null){
|
||||
switch (selectedItem.Tag){
|
||||
case "DownloadQueue":
|
||||
(sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
|
||||
break;
|
||||
case "AddDownload":
|
||||
(sender as NavigationView).Content = Activator.CreateInstance(typeof(AddDownloadPageViewModel));
|
||||
break;
|
||||
case "Calendar":
|
||||
(sender as NavigationView).Content = Activator.CreateInstance(typeof(CalendarPageViewModel));
|
||||
break;
|
||||
case "History":
|
||||
(sender as NavigationView).Content = Activator.CreateInstance(typeof(HistoryPageViewModel));
|
||||
navigationStack.Clear();
|
||||
navigationStack.Push((sender as NavigationView).Content);
|
||||
break;
|
||||
case "Account":
|
||||
(sender as NavigationView).Content = Activator.CreateInstance(typeof(AccountPageViewModel));
|
||||
break;
|
||||
case "Settings":
|
||||
(sender as NavigationView).Content = Activator.CreateInstance(typeof(SettingsPageViewModel));
|
||||
break;
|
||||
default:
|
||||
(sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class ToastMessage(string message, ToastType type, int i){
|
||||
public string? Message{ get; set; } = message;
|
||||
public int Seconds{ get; set; } = i;
|
||||
public ToastType Type{ get; set; } = type;
|
||||
}
|
||||
|
||||
public class NavigationMessage{
|
||||
public Type? ViewModelType{ get; }
|
||||
public bool Back{ get; }
|
||||
public bool Refresh{ get; }
|
||||
|
||||
public NavigationMessage(Type? viewModelType, bool back, bool refresh){
|
||||
ViewModelType = viewModelType;
|
||||
Back = back;
|
||||
Refresh = refresh;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels"
|
||||
xmlns:ui="clr-namespace:CRD.Utils.UI"
|
||||
xmlns:downloader="clr-namespace:CRD.Downloader"
|
||||
x:DataType="vm:SeriesPageViewModel"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="CRD.Views.SeriesPageView">
|
||||
|
||||
<Grid Margin="10">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<Button Grid.Row="0" Grid.Column="0" Command="{Binding NavBack}" Margin="0 0 0 10">Back</Button>
|
||||
|
||||
<Image Grid.Row="1" Grid.Column="0" Margin="10" Source="{Binding SelectedSeries.ThumbnailImage}" Width="240"
|
||||
Height="360">
|
||||
</Image>
|
||||
|
||||
|
||||
|
||||
<Grid Grid.Row="1" Grid.Column="1">
|
||||
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock Grid.Row="0" FontSize="50" Text="{Binding SelectedSeries.SeriesTitle}"></TextBlock>
|
||||
<TextBlock Grid.Row="1" FontSize="20" TextWrapping="Wrap" Text="{Binding SelectedSeries.SeriesDescription}"></TextBlock>
|
||||
<Button Grid.Row="3" Command="{Binding UpdateData}" Margin="0 0 0 10">Fetch Series</Button>
|
||||
</Grid>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<ScrollViewer Grid.Row="2" Grid.Column="0" Grid.ColumnSpan="2">
|
||||
<ItemsControl ItemsSource="{Binding SelectedSeries.Seasons}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
|
||||
<controls:SettingsExpander
|
||||
Header="{Binding CombinedProperty}"
|
||||
ItemsSource="{Binding EpisodesList}"
|
||||
|
||||
Description="{Binding SeasonTitle}"
|
||||
IsExpanded="False">
|
||||
|
||||
|
||||
|
||||
<controls:SettingsExpander.ItemTemplate>
|
||||
<DataTemplate>
|
||||
|
||||
<Grid VerticalAlignment="Center">
|
||||
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="E"></TextBlock>
|
||||
<TextBlock Text="{Binding Episode}"></TextBlock>
|
||||
<TextBlock Text=" - "></TextBlock>
|
||||
<TextBlock Text="{Binding EpisodeTitle}"></TextBlock>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
|
||||
|
||||
<Button Width="34" Height="34" Margin="0 0 10 0" Background="Transparent" BorderThickness="0" CornerRadius="50" IsVisible="{Binding !WasDownloaded}" Command="{Binding $parent[controls:SettingsExpander].((downloader:HistorySeason)DataContext).UpdateDownloaded}" CommandParameter="{Binding EpisodeId}">
|
||||
<Grid >
|
||||
<Ellipse Width="25" Height="25" Fill="Gray" />
|
||||
<controls:SymbolIcon Symbol="Checkmark" FontSize="18" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Width="34" Height="34" Margin="0 0 10 0" Background="Transparent" BorderThickness="0" CornerRadius="50" IsVisible="{Binding WasDownloaded}" Command="{Binding $parent[controls:SettingsExpander].((downloader:HistorySeason)DataContext).UpdateDownloaded}" CommandParameter="{Binding EpisodeId}">
|
||||
<Grid >
|
||||
<Ellipse Width="25" Height="25" Fill="#21a556" />
|
||||
<controls:SymbolIcon Symbol="Checkmark" FontSize="18" />
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<Button Margin="0 0 5 0" FontStyle="Italic" HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center" Command="{Binding DownloadEpisode}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:SymbolIcon Symbol="Download" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</controls:SettingsExpander.ItemTemplate>
|
||||
|
||||
<controls:SettingsExpander.Footer>
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding DownloadedEpisodes}" VerticalAlignment="Center"></TextBlock>
|
||||
<TextBlock Text="/" VerticalAlignment="Center"></TextBlock>
|
||||
<TextBlock Text="{Binding EpisodesList.Count}" VerticalAlignment="Center"></TextBlock>
|
||||
<Button Margin="10 0 0 0" FontStyle="Italic"
|
||||
VerticalAlignment="Center" Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).UpdateData}" CommandParameter="{Binding SeasonId}" >
|
||||
<ToolTip.Tip>
|
||||
<TextBlock Text="Fetch Season" FontSize="15" />
|
||||
</ToolTip.Tip>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<controls:SymbolIcon Symbol="Refresh" FontSize="18" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</controls:SettingsExpander.Footer>
|
||||
|
||||
</controls:SettingsExpander>
|
||||
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
|
@ -0,0 +1,11 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace CRD.Views;
|
||||
|
||||
public partial class SeriesPageView : UserControl{
|
||||
public SeriesPageView(){
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,399 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:vm="clr-namespace:CRD.ViewModels"
|
||||
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:DataType="vm:SettingsPageViewModel"
|
||||
x:Class="CRD.Views.SettingsPageView">
|
||||
|
||||
<Design.DataContext>
|
||||
<vm:SettingsPageViewModel />
|
||||
</Design.DataContext>
|
||||
|
||||
|
||||
<ScrollViewer Padding="20 20 20 0">
|
||||
<StackPanel Spacing="8">
|
||||
|
||||
|
||||
<controls:SettingsExpander Header="Dub language"
|
||||
IconSource="Speaker2"
|
||||
Description="Change the selected dub language">
|
||||
<controls:SettingsExpander.Footer>
|
||||
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
|
||||
ItemsSource="{Binding DubLangList}"
|
||||
SelectedItem="{Binding SelectedDubLang}">
|
||||
</ComboBox>
|
||||
</controls:SettingsExpander.Footer>
|
||||
|
||||
</controls:SettingsExpander>
|
||||
|
||||
|
||||
<controls:SettingsExpander Header="Hardsubs language"
|
||||
IconSource="FontColorFilled"
|
||||
Description="Change the selected hardsub language">
|
||||
<controls:SettingsExpander.Footer>
|
||||
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
|
||||
ItemsSource="{Binding HardSubLangList}"
|
||||
SelectedItem="{Binding SelectedHSLang}">
|
||||
</ComboBox>
|
||||
</controls:SettingsExpander.Footer>
|
||||
|
||||
</controls:SettingsExpander>
|
||||
|
||||
|
||||
<controls:SettingsExpander Header="Softsubs language"
|
||||
IconSource="FontColor"
|
||||
Description="Change the selected softsubs language">
|
||||
<controls:SettingsExpander.Footer>
|
||||
<StackPanel>
|
||||
<ToggleButton x:Name="dropdownButton" Width="210" HorizontalContentAlignment="Stretch">
|
||||
<ToggleButton.Content>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock HorizontalAlignment="Center" Text="{Binding SelectedSubs}"
|
||||
VerticalAlignment="Center" />
|
||||
<Path Grid.Column="1" Data="M 0,1 L 4,4 L 8,1" Stroke="White" StrokeThickness="1"
|
||||
VerticalAlignment="Center" Margin="5,0,5,0" Stretch="Uniform" Width="8" />
|
||||
</Grid>
|
||||
</ToggleButton.Content>
|
||||
</ToggleButton>
|
||||
<Popup IsLightDismissEnabled="True"
|
||||
IsOpen="{Binding IsChecked, ElementName=dropdownButton, Mode=TwoWay}" Placement="Bottom"
|
||||
PlacementTarget="{Binding ElementName=dropdownButton}">
|
||||
<Border BorderThickness="1" Background="{DynamicResource ComboBoxDropDownBackground}">
|
||||
<ListBox x:Name="listBoxSubsSelection" SelectionMode="Multiple,Toggle" Width="210"
|
||||
MaxHeight="400"
|
||||
ItemsSource="{Binding SubLangList}" SelectedItems="{Binding SelectedSubLang}">
|
||||
</ListBox>
|
||||
</Border>
|
||||
</Popup>
|
||||
</StackPanel>
|
||||
</controls:SettingsExpander.Footer>
|
||||
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<controls:SettingsExpander Header="History"
|
||||
IconSource="Clock"
|
||||
Description="Change if the download history is recorded">
|
||||
<controls:SettingsExpander.Footer>
|
||||
<CheckBox IsChecked="{Binding History}"> </CheckBox>
|
||||
</controls:SettingsExpander.Footer>
|
||||
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<controls:SettingsExpander Header="Download Settings"
|
||||
IconSource="Download"
|
||||
Description="Adjust download settings"
|
||||
IsExpanded="False">
|
||||
|
||||
<controls:SettingsExpanderItem Content="Simultaneous Downloads">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<controls:NumberBox Minimum="0" Maximum="5"
|
||||
Value="{Binding SimultaneousDownloads}"
|
||||
SpinButtonPlacementMode="Inline"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Video">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding DownloadVideo}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Video Quality">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
|
||||
ItemsSource="{Binding VideoQualityList}"
|
||||
SelectedItem="{Binding SelectedVideoQuality}">
|
||||
</ComboBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Audio">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding DownloadAudio}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Audio Quality">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
|
||||
ItemsSource="{Binding AudioQualityList}"
|
||||
SelectedItem="{Binding SelectedAudioQuality}">
|
||||
</ComboBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Chapters">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding DownloadChapters}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpander.Footer>
|
||||
|
||||
</controls:SettingsExpander.Footer>
|
||||
</controls:SettingsExpander>
|
||||
|
||||
|
||||
<controls:SettingsExpander Header="Filename Settings"
|
||||
IconSource="Edit"
|
||||
Description="Change how the files are named"
|
||||
IsExpanded="False">
|
||||
|
||||
<controls:SettingsExpanderItem Content="Leading 0 for seasons and episodes">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<controls:NumberBox Minimum="0" Maximum="5"
|
||||
Value="{Binding LeadingNumbers}"
|
||||
SpinButtonPlacementMode="Inline"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Filename"
|
||||
Description="${showTitle} ${seriesTitle} ${title} ${season} ${episode} ${height} ${width}">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<TextBox Name="FileNameTextBox" HorizontalAlignment="Left" MinWidth="250"
|
||||
Text="{Binding FileName}" />
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpander.Footer>
|
||||
|
||||
</controls:SettingsExpander.Footer>
|
||||
</controls:SettingsExpander>
|
||||
|
||||
|
||||
<controls:SettingsExpander Header="Muxing Settings"
|
||||
IconSource="Repair"
|
||||
Description="MKVMerge and FFMpeg Settings"
|
||||
IsExpanded="False">
|
||||
|
||||
<controls:SettingsExpanderItem Content="MP4">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<CheckBox IsChecked="{Binding MuxToMp4}"> </CheckBox>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Additional MKVMerge Options">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<TextBox IsEnabled="False" Name="TargetTextBox2" HorizontalAlignment="Left" MinWidth="250"
|
||||
Text="{Binding MkvMergeOptions}" />
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem Content="Additional FFMpeg Options">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<TextBox IsEnabled="False" Name="TargetTextBox3" HorizontalAlignment="Left" MinWidth="250"
|
||||
Text="{Binding FfmpegOptions}" />
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpander.Footer>
|
||||
|
||||
</controls:SettingsExpander.Footer>
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<controls:SettingsExpander Header="App Theme"
|
||||
IconSource="DarkTheme"
|
||||
Description="Change the current app theme">
|
||||
|
||||
<controls:SettingsExpander.Footer>
|
||||
<ComboBox SelectedItem="{Binding CurrentAppTheme}"
|
||||
ItemsSource="{Binding AppThemes}"
|
||||
MinWidth="150" />
|
||||
</controls:SettingsExpander.Footer>
|
||||
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<controls:SettingsExpander Header="App Accent Color"
|
||||
IconSource="ColorLine"
|
||||
Description="Set a custom accent color for the App"
|
||||
IsExpanded="False">
|
||||
|
||||
<controls:SettingsExpanderItem Content="Preview">
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<Grid RowDefinitions="*,*,*,*"
|
||||
ColumnDefinitions="*,*"
|
||||
HorizontalAlignment="Right"
|
||||
Grid.Column="1">
|
||||
<Border Background="{DynamicResource SystemAccentColor}"
|
||||
Height="40" Grid.ColumnSpan="2">
|
||||
<TextBlock Text="SystemAccentColor"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource SystemAccentColorLight1}"
|
||||
Height="40" Width="90" Grid.Column="0" Grid.Row="1">
|
||||
<TextBlock Text="Light1"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Border Background="{DynamicResource SystemAccentColorLight2}"
|
||||
Height="40" Width="90" Grid.Column="0" Grid.Row="2">
|
||||
<TextBlock Text="Light2"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Border Background="{DynamicResource SystemAccentColorLight3}"
|
||||
Height="40" Width="90" Grid.Column="0" Grid.Row="3">
|
||||
<TextBlock Text="Light3"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<Border Background="{DynamicResource SystemAccentColorDark1}"
|
||||
Height="40" Width="90" Grid.Column="1" Grid.Row="1">
|
||||
<TextBlock Text="Dark1"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Border Background="{DynamicResource SystemAccentColorDark2}"
|
||||
Height="40" Width="90" Grid.Column="1" Grid.Row="2">
|
||||
<TextBlock Text="Dark2"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
<Border Background="{DynamicResource SystemAccentColorDark3}"
|
||||
Height="40" Width="90" Grid.Column="1" Grid.Row="3">
|
||||
<TextBlock Text="Dark3"
|
||||
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
</Grid>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
<controls:SettingsExpanderItem>
|
||||
<CheckBox Content="Use Custom Accent Color?"
|
||||
IsChecked="{Binding UseCustomAccent}"
|
||||
HorizontalAlignment="Right" />
|
||||
<controls:SettingsExpanderItem.Footer>
|
||||
<StackPanel>
|
||||
<TextBlock Text="Pre-set Colors"
|
||||
Margin="24 24 0 0"
|
||||
IsVisible="{Binding UseCustomAccent}" />
|
||||
|
||||
<ListBox ItemsSource="{Binding PredefinedColors}"
|
||||
SelectedItem="{Binding ListBoxColor}"
|
||||
MaxWidth="441"
|
||||
AutoScrollToSelectedItem="False"
|
||||
Margin="24 0 24 12"
|
||||
HorizontalAlignment="Left"
|
||||
IsVisible="{Binding UseCustomAccent}">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
|
||||
<ListBox.Styles>
|
||||
<Style Selector="ListBoxItem">
|
||||
<Setter Property="Width" Value="48" />
|
||||
<Setter Property="Height" Value="48" />
|
||||
<Setter Property="MinWidth" Value="0" />
|
||||
<Setter Property="Margin" Value="1 1 0 0" />
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
<Panel>
|
||||
<Border CornerRadius="{StaticResource ControlCornerRadius}"
|
||||
BorderThickness="2"
|
||||
Name="Root">
|
||||
<Border.Background>
|
||||
<SolidColorBrush Color="{Binding}" />
|
||||
</Border.Background>
|
||||
</Border>
|
||||
|
||||
<Border Name="Check"
|
||||
Background="{DynamicResource FocusStrokeColorOuter}"
|
||||
Width="20" Height="20"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0 2 2 0">
|
||||
<controls:SymbolIcon Symbol="Checkmark"
|
||||
Foreground="{DynamicResource SystemAccentColor}"
|
||||
FontSize="18" />
|
||||
</Border>
|
||||
</Panel>
|
||||
</ControlTemplate>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem /template/ Border#Check">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:pointerover /template/ Border#Root">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource FocusStrokeColorOuter}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBoxItem:selected /template/ Border#Root">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource FocusStrokeColorOuter}" />
|
||||
</Style>
|
||||
<Style Selector="ListBoxItem:selected /template/ Border#Check">
|
||||
<Setter Property="IsVisible" Value="True" />
|
||||
</Style>
|
||||
</ListBox.Styles>
|
||||
|
||||
</ListBox>
|
||||
|
||||
<Rectangle Fill="{DynamicResource ApplicationPageBackgroundThemeBrush}"
|
||||
Height="1"
|
||||
IsVisible="{Binding UseCustomAccent}" />
|
||||
|
||||
<DockPanel LastChildFill="False" Margin="24 6 0 0"
|
||||
IsVisible="{Binding UseCustomAccent}">
|
||||
<TextBlock Text="Custom Color"
|
||||
VerticalAlignment="Center"
|
||||
DockPanel.Dock="Left" />
|
||||
|
||||
<controls:ColorPickerButton Color="{Binding CustomAccentColor}"
|
||||
IsMoreButtonVisible="True"
|
||||
UseSpectrum="True"
|
||||
UseColorWheel="False"
|
||||
UseColorTriangle="False"
|
||||
UseColorPalette="False"
|
||||
IsCompact="True" ShowAcceptDismissButtons="True"
|
||||
DockPanel.Dock="Right" />
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
</controls:SettingsExpanderItem.Footer>
|
||||
</controls:SettingsExpanderItem>
|
||||
|
||||
</controls:SettingsExpander>
|
||||
|
||||
|
||||
<Grid Margin="0 0 0 10"
|
||||
ColumnDefinitions="*,Auto" RowDefinitions="*,Auto">
|
||||
|
||||
<DockPanel HorizontalAlignment="Center">
|
||||
<Image Source="/Assets/app_icon.ico"
|
||||
DockPanel.Dock="Left"
|
||||
Height="78"
|
||||
RenderOptions.BitmapInterpolationMode="HighQuality" />
|
||||
|
||||
<StackPanel Spacing="0" Margin="12 0">
|
||||
<TextBlock Text="Crunchy-Downloader"
|
||||
Theme="{StaticResource TitleTextBlockStyle}" />
|
||||
|
||||
<TextBlock Text="{Binding CurrentVersion}"
|
||||
Theme="{StaticResource BodyTextBlockStyle}" />
|
||||
|
||||
<TextBlock Theme="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="https://github.com/Crunchy-DL/Crunchy-Downloader"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
</UserControl>
|
|
@ -0,0 +1,12 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using CRD.ViewModels;
|
||||
|
||||
namespace CRD.Views;
|
||||
|
||||
public partial class SettingsPageView : UserControl{
|
||||
public SettingsPageView(){
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="CRD.Views.ToastNotification"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Bottom" Margin="10">
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Border.info">
|
||||
<Setter Property="Background" Value="#0078D7" />
|
||||
<Setter Property="Padding" Value="20" />
|
||||
<Setter Property="CornerRadius" Value="5" />
|
||||
</Style>
|
||||
<Style Selector="Border.error">
|
||||
<Setter Property="Background" Value="#D13438" />
|
||||
<Setter Property="Padding" Value="20" />
|
||||
<Setter Property="CornerRadius" Value="5" />
|
||||
</Style>
|
||||
<Style Selector="Border.warning">
|
||||
<Setter Property="Background" Value="#FF8C00" />
|
||||
<Setter Property="Padding" Value="20" />
|
||||
<Setter Property="CornerRadius" Value="5" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<!-- Border that will use dynamic styles -->
|
||||
<Border x:Name="MessageBorder">
|
||||
<TextBlock x:Name="MessageText" Foreground="White" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
|
@ -0,0 +1,51 @@
|
|||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Threading;
|
||||
|
||||
namespace CRD.Views;
|
||||
|
||||
public partial class ToastNotification : UserControl{
|
||||
public ToastNotification(){
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void InitializeComponent(){
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void Show(string message, ToastType type, int durationInSeconds){
|
||||
this.FindControl<TextBlock>("MessageText").Text = message;
|
||||
SetStyle(type);
|
||||
DispatcherTimer timer = new DispatcherTimer{ Interval = TimeSpan.FromSeconds(durationInSeconds) };
|
||||
timer.Tick += (sender, args) => {
|
||||
timer.Stop();
|
||||
this.IsVisible = false;
|
||||
};
|
||||
timer.Start();
|
||||
this.IsVisible = true;
|
||||
}
|
||||
|
||||
private void SetStyle(ToastType type){
|
||||
var border = this.FindControl<Border>("MessageBorder");
|
||||
border.Classes.Clear(); // Clear previous styles
|
||||
switch (type){
|
||||
case ToastType.Information:
|
||||
border.Classes.Add("info");
|
||||
break;
|
||||
case ToastType.Error:
|
||||
border.Classes.Add("error");
|
||||
break;
|
||||
case ToastType.Warning:
|
||||
border.Classes.Add("warning");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ToastType{
|
||||
Information,
|
||||
Error,
|
||||
Warning
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="200" d:DesignHeight="200"
|
||||
x:Class="CRD.Views.Utils.ErrorWindow"
|
||||
Icon="/Assets/app_icon.ico"
|
||||
Title="Error" Width="300" Height="150">
|
||||
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="*" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<TextBlock HorizontalAlignment="Center" TextAlignment="Center" Margin="10" x:Name="ErrorMessage" Text="Error goes here" TextWrapping="Wrap" />
|
||||
<Button Grid.Row="1" VerticalAlignment="Bottom" HorizontalAlignment="Center" Content="Close" Click="Close_Click"/>
|
||||
</Grid>
|
||||
|
||||
|
||||
</Window>
|
|
@ -0,0 +1,23 @@
|
|||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace CRD.Views.Utils;
|
||||
|
||||
public partial class ErrorWindow : Window{
|
||||
public ErrorWindow(){
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private void Close_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e){
|
||||
Close();
|
||||
}
|
||||
|
||||
private void InitializeComponent(){
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void SetErrorMessage(string message){
|
||||
this.FindControl<TextBlock>("ErrorMessage").Text = message;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue